diff --git a/.github/skills/webview-trpc-messaging/SKILL.md b/.github/skills/webview-trpc-messaging/SKILL.md index c8f16fc9c..6f1be594d 100644 --- a/.github/skills/webview-trpc-messaging/SKILL.md +++ b/.github/skills/webview-trpc-messaging/SKILL.md @@ -23,13 +23,15 @@ useTrpcClient() hook WebviewController | File | Purpose | | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `@microsoft/vscode-ext-react-webview/server` (trpc) | tRPC init, `publicProcedure`, `createMiddleware`, `router`, `BaseRouterContext` | -| `src/webviews/_integration/appRouter.ts` | Root router + telemetry middleware + `publicProcedureWithTelemetry` + `WithTelemetry` + DocumentDB `BaseRouterContext` | +| `@microsoft/vscode-ext-webview` (shared) | tRPC init via `initWebviewTrpc`, `publicProcedure`, `router`, `BaseRouterContext` | +| `@microsoft/vscode-ext-webview/host` (telemetry) | `telemetryMiddlewareBody`, `ProcedureLogger`, `TelemetryRunner` (consumer builds `publicProcedureWithTelemetry`) | +| `src/webviews/_integration/trpc.ts` | Consumer tRPC instance: `publicProcedureWithTelemetry`, the DocumentDB `TelemetryRunner`, `WithTelemetry` | +| `src/webviews/_integration/appRouter.ts` | Root router + `publicProcedureWithTelemetry` wiring + DocumentDB `BaseRouterContext` | | `src/webviews/_integration/configuration.ts` | Consumer-owned knobs (telemetry namespace, bundle layout, dev-server host) | -| `@microsoft/vscode-ext-react-webview/server` (WebviewController) | WebviewPanel lifecycle, tRPC message dispatcher (queries, mutations, subscriptions, abort) | -| `src/webviews/_integration/WebviewControllerBase.ts` | DocumentDB-tuned base class that pre-fills router + bundle layout | +| `@microsoft/vscode-ext-webview/host` (WebviewController) | `WebviewController` + `openWebview` factory: WebviewPanel lifecycle, tRPC dispatcher (queries, mutations, subscriptions, abort) | +| `src/webviews/_integration/openAppWebview.ts` | DocumentDB factory preset that pre-fills router + bundle layout (`openAppWebview`) | | `src/webviews/_integration/useTrpcClient.ts` | React hook providing the tRPC client (pre-typed against `AppRouter`) | -| `@microsoft/vscode-ext-react-webview` (vscodeLink) | Custom tRPC link bridging `postMessage` transport | +| `@microsoft/vscode-ext-webview/webview` (vscodeLink) | Custom tRPC link bridging `postMessage` transport | ## Creating a New Router @@ -91,33 +93,53 @@ export const appRouter = router({ ### 4. Create the controller +Construction-only panels are opened with a factory function that builds the +config + router context and calls the `openAppWebview` preset (which pre-fills +the app router, caller factory, and bundle layout): + ```typescript // src/webviews/documentdb/myView/myViewController.ts -import { WebviewControllerBase } from '../../_integration/WebviewControllerBase'; +import * as vscode from 'vscode'; +import { API } from '../../../DocumentDBExperiences'; +import { type AppWebviewController, openAppWebview } from '../../_integration/openAppWebview'; import { type RouterContext } from './myViewRouter'; -export class MyViewController extends WebviewControllerBase { - constructor(initialData: MyViewConfig) { - super(ext.context, title, 'myViewName', initialData); - - const trpcContext: RouterContext = { - dbExperience: API.DocumentDB, - webviewName: 'myView', - clusterId: initialData.clusterId, - viewId: initialData.viewId, - databaseName: initialData.databaseName, - }; - - this.setupTrpc(trpcContext); - } +export function openMyViewPanel(initialData: MyViewConfig): AppWebviewController { + const title = `${initialData.databaseName}`; + + const trpcContext: RouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'myView', + clusterId: initialData.clusterId, + viewId: initialData.viewId, + databaseName: initialData.databaseName, + }; + + return openAppWebview({ + title, + webviewName: 'myView', + config: initialData, + context: trpcContext, + }); } ``` -> **Important:** The `webviewName` in the `WebviewControllerBase` constructor is the **registry key** (must match a key in `WebviewRegistry`, e.g. `collectionView`). The `webviewName` in the tRPC context is a **telemetry label** used in telemetry event names (e.g. `collectionView`). These may be the same string but serve different purposes — do not confuse them. +The returned `AppWebviewController` handle exposes `panel`, `onDisposed`, +`revealToForeground`, `isDisposed`, and `dispose`. Genuinely stateful panels may +still extend `WebviewController` from `@microsoft/vscode-ext-webview/host` +directly instead of using the factory. + +> **Important:** The `webviewName` field passed to `openAppWebview` is the +> **registry key** (`viewType`, must match a key in `WebviewRegistry`, e.g. +> `collectionView`). The `webviewName` in the tRPC context is a **telemetry +> label** used in telemetry event names. These may be the same string but serve +> different purposes -- do not confuse them. ### 5. Register in WebviewRegistry -Add your React component to the registry. The key must match the `webviewName` passed to `WebviewControllerBase`'s constructor. The `WebviewName` type (exported from the same file) ensures compile-time validation of webview names. +Add your React component to the registry. The key must match the `webviewName` +passed to `openAppWebview` (`viewType`). The `WebviewName` type (exported from +the same file) ensures compile-time validation of webview names. ```typescript // src/webviews/_integration/WebviewRegistry.ts @@ -191,7 +213,7 @@ getData: publicProcedureWithTelemetry ### Client-side abort ```tsx -const { trpcClient } = useTrpcClient(); +const trpcClient = useTrpcClient(); const abortControllerRef = useRef(); const runQuery = async () => { @@ -241,10 +263,10 @@ sub.unsubscribe(); ```tsx import { useTrpcClient } from '../_integration/useTrpcClient'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; export const MyComponent = () => { - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const config = useConfiguration(); useEffect(() => { diff --git a/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-implementation-plan.md b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-implementation-plan.md new file mode 100644 index 000000000..51bba4e72 --- /dev/null +++ b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-implementation-plan.md @@ -0,0 +1,1414 @@ +# Implementation plan: `@microsoft/vscode-ext-webview` redesign + migration + +**Audience:** an autonomous coding agent. +**Source of truth for the API:** [webview-rpc-package-decoupling-design.md](./webview-rpc-package-decoupling-design.md) **§13 (Decisions summary)**. Where this plan and §13 disagree, **§13 wins**; stop and flag the conflict. +**Date:** 2026-06-29. + +This plan reshapes the preview webview package into the layered design locked in +§13, renames it, and migrates the `vscode-documentdb` extension onto it. The old +package is deleted only after the migration is green. + +--- + +## 0. How to use this plan + +- Execute phases **A → G in order**. Within a phase, execute work items (WI-xx) + in listed order. +- Each work item is **one commit** (plus follow-up commits for fixes; see rules). +- Every work item has a **"Done when"** checkpoint list. Do not start the next + work item until the current one's checkpoints all pass. +- The API names, subpaths, and tiers are fixed by §13.4. Do not invent names. +- **Before any work:** run the preflight sync with `main` (WI-A0). +- **After every work item:** append a progress-log entry (§8) and include it in + that work item's commit. This is the resume trail if the session dies (§1.7). +- **Delegate context-heavy work to subagents** per §1.6. + +--- + +## 1. Operating rules (mandatory) + +### 1.1 Persistence and the confidence gate + +- **Do not abort.** Keep working until every checkpoint in the Definition of + Done (§Definition of Done) is reached. +- **Stop and ask the operator only when your confidence in the next action is + below 80%**, specifically when: + - the design intent is ambiguous and §13 does not resolve it; + - a checkpoint fails and you cannot fix it in a bounded follow-up commit; + - an action is destructive or irreversible beyond what this plan authorises; + - a test whose name starts with `TDD:` fails (repo rule: never auto-fix a + `TDD:` contract; ask); + - the migration appears to require changes an order of magnitude larger than + this plan estimates. +- When you stop, post: what you were doing, the failing checkpoint or ambiguity, + options considered, and your recommended next step. + +### 1.2 Commits (review-friendly, no mega-commit) + +- **One work item = one commit.** Never bundle multiple work items into a single + commit. Never make one big commit with everything. +- Use Conventional Commit messages scoped to the package or consumer, e.g. + `refactor(webview-ext): extract attachTrpc dispatcher`, + `feat(webview-ext): add openWebview factory`, + `docs(webview-ext): quick-start README`, + `chore(documentdb): migrate _integration to @microsoft/vscode-ext-webview`. +- Commit only after that work item's **per-commit verification** (§1.4) passes. + +### 1.3 Fixes are follow-up commits (no rewrites) + +- When an error or a failing checkpoint is found **after** a commit, fix it in a + **new follow-up commit** (e.g. `fix(webview-ext): correct ./react export map`). +- **Never** `git commit --amend`, **never** rebase/squash/rewrite history, + **never** force-push. The history is the review trail. + +### 1.4 Tiered verification (fast commits, full checks at milestones) + +Running the whole PR checklist on every commit is too slow. Use two tiers. + +**Per commit (fast, every commit):** + +- `npm run lint` on every commit. Lint plays the same correctness role as the + build but is cheap, so it is the standing per-commit gate (the heavy repo-wide + build is deferred to milestones). "Lint is like build, run it often." +- **Scoped tests only.** Run Jest limited to the files or folder you touched, + e.g. `npx jest ` (or the `runTests` files filter). Do **not** + run the full suite on every commit. +- For a commit that touches the package, the **local** `tsc` typecheck + (`npm run build` inside `packages/vscode-ext-webview/`, which is fast) is fine + to confirm the package still compiles. The **repo-wide** build stays a + milestone check. + +**At milestones and before declaring Done (full):** + +- `npm run build` (full repo build, all workspaces). +- `npx jest --no-coverage` (full suite). +- `npm run prettier-fix`. +- `npm run l10n` (only if user-facing strings changed; otherwise skip). + +**Milestones** (run the full set): + +- end of **Phase C** and **Phase D** ("the new webview API package is ready"); +- end of **Phase E** (extension migration complete); +- immediately **before and after Phase F** (the deletion); +- the final **Definition of Done** gate. + +If a milestone full-run surfaces an error, fix it in a **follow-up commit** +(never an amend) and re-run that milestone's full set until green. + +> Throughout the per-work-item "Done when" checkpoints below, phrases like +> "build + tests green" mean the **per-commit tier** (lint + scoped tests + +> the fast local package compile). The full repo build and full suite are owed +> only at the milestones above. + +### 1.5 Git safety + +- Work on the current feature branch (or a dedicated branch off it). **Never + commit to `main`.** +- **Never** `git add -f`. If `git add` reports a path is ignored + (`docs/plan/`, `docs/analysis/`, build output), stop and report; do not force. +- Do not push, open PRs, or delete branches unless the operator asks. +- Terminology: **DocumentDB**, never "MongoDB" alone (repo rule), in any new + strings, comments, or docs. +- Documentation style: **no em dashes (`—`) and no en dashes (`–`)** in any docs + you author (package `README.md`, `ADVANCED.md`, the migration manual). Use + plain ASCII punctuation only (commas, parentheses, colons; the word "to" for + ranges). + +### 1.6 Subagents (keep the main context clean) + +Subagents run with an **isolated context** and return only a concise summary. +Use them for **read-heavy or output-heavy, self-contained** tasks so their +transient detail (file dumps, multi-thousand-line build/test output, doc diffs) +never enters and bloats the main thread. A bloated main context degrades +long-horizon coherence across 20+ commits, so this is a correctness measure, not +just tidiness. + +Rules: + +- Give a subagent a **complete, self-contained prompt** (it cannot see this + conversation) and ask for a **short structured summary**. +- Prefer the read-only **Explore** agent for pure research/inspection; use a + general subagent for tasks that also run commands. +- Keep **public-API design and cross-cutting implementation in the main agent**: + the evolving API shape (names, generics, exports) must stay in the main + context to remain coherent between work items. + +Recommended split: + +| Task | Subagent? | Why | +| --------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Inventory every consumer call site before Phase E | Yes (Explore) | Many file reads; only a concise call-site list returns. | +| Milestone full verification (full build + full jest) + failure triage | Yes | Output is thousands of lines; subagent returns pass/fail plus a short failure digest. | +| Documentation parity audit (old README vs new README + `ADVANCED.md`) | Yes (Explore) | Reads two large docs to diff topic coverage; returns a gap list only. | +| Final Definition-of-Done audit (greps, parity, checklist) | Yes | Bundles many read-only checks into one pass/fail report. | +| External research (re-checking Cosmos patterns, tRPC docs) | Yes (Explore) | Web/repo reading; a summary suffices. | +| Implementing a Phase C API module | Main agent | API coherence must live in the main thread. A subagent is allowed only if the module is fully specified by §13, and the main agent then reads the produced files before continuing. | +| README / `ADVANCED.md` prose | Main agent | Needs the evolving API in working memory and must hit the parity bar (Phase D). | +| Per-commit scoped tests | Main agent | Fast; the result is needed inline. | + +Record each subagent's outcome in the relevant work item's progress-log entry. + +### 1.7 Progress log and resumability + +After **every** work item, before moving on: + +1. Append an entry to the **Progress log (§8)** using the template there: WI id, + status, a one-to-three line summary of what was done, the checks that ran, + and a **Deviations** field. +2. If you deviated from this plan in any way, the Deviations field must record + **what** changed, **why**, **which alternatives** you considered, and **why** + you chose this one. If there was no deviation, write "None." +3. **Include the progress-log edit in that work item's commit** (each commit + carries its own journal entry; no separate commit, no amend). The only + exception is the WI-A0 merge commit, which cannot carry file edits: fold its + entry into WI-A1's commit. + +**Resuming after a failure or in a new session:** (a) run the preflight sync +(WI-A0); (b) read §8 and `git --no-pager log --oneline` to find the last +completed work item; (c) confirm the working tree is clean and that work item's +checks are green; (d) continue from the next work item. The progress log plus +the commit history are the single source of "where we are." + +--- + +## 2. Strategy: build beside, migrate, then delete + +To keep the extension building at all times: + +1. **Phases A–D** build the new package at `packages/vscode-ext-webview/` as a + **copy-and-evolve** of the old one. The old `packages/vscode-ext-react-webview/` + stays untouched and the extension keeps importing it. +2. **Phase E** migrates the extension to the new package and names. +3. **Phase F** deletes the old package once everything is green. +4. **Phase G** drafts the internal migration manual. + +At no point between phases should the extension be unbuildable. + +--- + +## 3. Phases and work items + +> Legend for each WI: **Goal**, **Steps**, **Commit**, **Done when** (checkpoints). + +### Phase A — Scaffold the new package + +**WI-A0 — Preflight: start from the latest `main`.** + +- Goal: begin on top of the newest `main` so the rework does not diverge from a + stale base. +- Steps: ensure you are on the working branch that contains this plan and the + design doc. `git fetch origin`, then **merge** `origin/main` into the working + branch (`git merge origin/main`); do **not** rebase or rewrite history. + Resolve any conflicts (favouring the design/plan docs in `docs/ai-and-plans/`). + Run `npm install`. +- Commit: the merge commit produced by `git merge origin/main` (no separate code + commit). If the branch is already current with `origin/main`, no merge commit + is created, which is fine. +- Done when: the branch contains the latest `origin/main`; `npm install` + succeeds; the repo builds and lints clean on the synced base (full milestone + verification, §1.4); §8 has a WI-A0 entry (folded into WI-A1's commit). + +**WI-A1 — Copy the package into the new folder.** + +- Goal: a building copy at `packages/vscode-ext-webview/`, old package still intact. +- Steps: copy `packages/vscode-ext-react-webview/` to `packages/vscode-ext-webview/` + with `git mv`-style copy (use `cp -R` then `git add`, since the original must + remain). Ensure the workspace globber in the root `package.json` picks it up + (it should via `packages/*`); run `npm install` so the workspace symlink is created. +- Commit: `chore(webview-ext): scaffold vscode-ext-webview as a copy`. +- Done when: `npm install` succeeds; old package builds; new package builds + (`npm run build` in the new folder); extension still builds against the old one. + +**WI-A2 — Rebrand package metadata.** + +- Goal: new identity and version, no behavior change. +- Steps: in `packages/vscode-ext-webview/package.json` set `name` to + `@microsoft/vscode-ext-webview`, `version` to `0.9.0-preview`, update + `repository.directory` to `packages/vscode-ext-webview`, and update the + `description` to reflect "transport + optional panel facade" (drop the + React-centric wording). Leave `exports` for the next WI. +- Commit: `chore(webview-ext): rename package to @microsoft/vscode-ext-webview@0.9.0-preview`. +- Done when: new package builds; `npm ls @microsoft/vscode-ext-webview` resolves + in the workspace; old package and extension still build. + +### Phase B — Restructure to the four-subpath layout + +Target source layout (folders map to subpaths per §13.2): + +``` +packages/vscode-ext-webview/src/ + index.ts -> "." shared + host.ts -> "./host" + webview.ts -> "./webview" + react.ts -> "./react" + shared/ wire types, TypedEventSink, BaseRouterContext, initWebviewTrpc, tRPC re-exports + host/ attachTrpc, WebviewController, openWebview, middleware/ (bodies + adapters) + webview/ connectTrpc, events (createEventChannel/RpcEventChannel), vscodeLink, errorLink + react/ useTrpcClient, useRpcEvents, WebviewContext, useConfiguration +``` + +**WI-B1 — Create the shared entry and move shared code.** + +- Goal: `./` is side-agnostic (no `vscode`, no React). +- Steps: create `src/shared/`. Move `BaseRouterContext.ts` and `TypedEventSink.ts` + there. Extract the wire-protocol types (`VsCodeLinkRequestMessage`, + `VsCodeLinkResponseMessage`, `StopOperation`, etc.) out of `vscodeLink.ts` into + `src/shared/wireProtocol.ts`. Create `src/index.ts` re-exporting the shared + surface. Fix imports across the package. +- Commit: `refactor(webview-ext): introduce shared entry (wire types, TypedEventSink, BaseRouterContext)`. +- Done when: package builds; `import {} from '.'` exposes only side-agnostic + symbols; no `vscode`/`react` import is reachable from `src/index.ts` + (verify by grep on the shared subtree). + +**WI-B2 — Create the host entry and move host code.** + +- Goal: `./host` holds extension-host code. +- Steps: create `src/host/`. Move `WebviewController.ts` and `trpc.ts` here as a + starting point (they get reshaped in Phase C). Create `src/host.ts` re-exporting + the host surface. Fix imports. +- Commit: `refactor(webview-ext): introduce host entry`. +- Done when: package builds; tests pass; `./host` resolves. + +**WI-B3 — Create the webview + react entries and split React out.** + +- Goal: `./webview` is framework-agnostic; `./react` is the only React importer. +- Steps: create `src/webview/` (move `vscodeLink.ts`, `errorLink.ts`) and + `src/react/` (move `WebviewContext.tsx`, `useConfiguration.ts`, `useTrpcClient.ts`). + Create `src/webview.ts` and `src/react.ts` entries. Fix imports. +- Commit: `refactor(webview-ext): split framework-agnostic webview entry from react entry`. +- Done when: package builds; `./webview` has no `react` import (grep); + `./react` compiles. + +**WI-B4 — Wire the exports map and optional React peer.** + +- Goal: package.json reflects the four subpaths. +- Steps: set `exports` for `.`, `./host`, `./webview`, `./react` (each with + `types` + `default` pointing at `dist/*.js` / `dist/*.d.ts`); update + `typesVersions`; mark `react` as `optional` in `peerDependenciesMeta`; keep + `@trpc/*` peers and `vscode-webview` optional peer. +- Commit: `chore(webview-ext): export ./host, ./webview, ./react subpaths`. +- Done when: `npm run build` emits all four entry `.js` + `.d.ts`; a scratch + type-only import of each subpath compiles; old package and extension still build. + +### Phase C — Implement the new API (one work item per capability) + +> Each WI here changes/adds public API per §13.4 and ships/updates Jest tests. +> Retire old symbols in the same WI that replaces them. + +**WI-C1 — `initWebviewTrpc()` typed init (shared).** + +- Goal: consumer-owned, context-typed tRPC root that removes the `ctx as T` cast. +- Steps: add `src/shared/initWebviewTrpc.ts` returning + `{ router, publicProcedure, createCallerFactory }` (and any helpers §13.6 + implies) bound to `TContext extends BaseRouterContext`. Re-export `router` / + `publicProcedure` from `.`. Add tests. +- Commit: `feat(webview-ext): add initWebviewTrpc typed-init helper`. +- Done when: a test router built via `initWebviewTrpc()` infers `ctx` with + no cast; package build + tests green. + +**WI-C2 — Telemetry: middleware bodies + adapters (host); retire old model.** + +- Goal: instance-agnostic telemetry per §13.5. +- Steps: add `src/host/middleware/` with `types.ts` + (`ProcedureInvocation`, `ProcedureType`, `MiddlewareResultLike`), + `loggingMiddleware.ts` (`ProcedureLogger`, `loggingMiddlewareBody`, + `consoleProcedureLogger`), `telemetryMiddleware.ts` (`TelemetryRunner`, + `telemetryMiddlewareBody`). **Remove** `createMiddleware`, + `publicProcedureWithTelemetry`, and the `TelemetryContext` type from the + package. Add tests for both bodies (success / error / aborted paths). +- Commit: `feat(webview-ext)!: replace bound telemetry with middleware bodies + adapters`. +- Done when: tests cover logger + runner; no reference to the removed symbols + remains in the package (grep); build + tests green. + +**WI-C3 — Extract `attachTrpc` (host primitive).** + +- Goal: a free `attachTrpc(panel, ctx, router, createCallerFactory)` that owns + the dispatch pump; `WebviewController` calls it. +- Steps: move the message-pump logic (the four handlers, `safePostMessage`, + `toAsyncIterator`, abort/subscription lifecycle) out of `WebviewController` + into `src/host/attachTrpc.ts`, returning + `{ disposable, activeOperations, activeSubscriptions }`. Refactor + `WebviewController.setupTrpc()` to call `attachTrpc` and register the + disposable. Accept a consumer `createCallerFactory` (default to the one from + an internal `initWebviewTrpc` instance when omitted). Port/extend the existing + dispatch tests to target `attachTrpc` directly. +- Commit: `refactor(webview-ext): extract attachTrpc dispatcher from WebviewController`. +- Done when: `attachTrpc` has direct unit tests; `WebviewController` behavior is + unchanged (existing tests green); build green. + +**WI-C4 — Event channel + `errorLink` shim (webview).** + +- Goal: `createEventChannel()` / `RpcEventChannel` (`onSuccess`/`onError`/`onAborted`). +- Steps: add `src/webview/events.ts` (`createEventChannel`, `RpcEventChannel`, + `RpcEventEmitter`, handler types). Refactor `errorLink` into a thin shim that + publishes into a channel. Add tests (snapshot-iteration safety, abort vs error + separation). +- Commit: `feat(webview-ext): add createEventChannel and refit errorLink as a shim`. +- Done when: channel tests green; `errorLink` still satisfies its existing tests; + build green. + +**WI-C5 — `connectTrpc` (webview primitive).** + +- Goal: `connectTrpc(vscodeApi, options?) -> { client, events }` bundling + `createEventChannel` + `vscodeLink` + `errorLink` + the default + `send`/`onReceive` wiring. +- Steps: add `src/webview/connectTrpc.ts`. Add tests (client wired, events + surfaced, abort path). +- Commit: `feat(webview-ext): add connectTrpc webview client factory`. +- Done when: a test drives a query through `connectTrpc` and observes a channel + event; build + tests green. + +**WI-C6 — Split hooks (react), built on `connectTrpc`.** + +- Goal: `useTrpcClient()` returns the client; `useRpcEvents()` returns the + channel; one memoised instance per webview. +- Steps: refactor `react/useTrpcClient.ts` to return the client only; add + `react/useRpcEvents.ts`; both read from a single per-`vscodeApi` memoised + `connectTrpc` result. Update/replace hook tests. +- Commit: `feat(webview-ext)!: split useTrpcClient (client) and useRpcEvents (channel)`. +- Done when: both hooks return values from the same instance (test asserts + identity stability); build + tests green. + +**WI-C7 — Factory `openWebview` + options-bag constructor (host).** + +- Goal: the greenfield front door returning a `WebviewController` instance; + modernise the class constructor to a single options object. +- Steps: change `WebviewController`'s constructor to accept one options object + (`{ extensionContext, title, viewType, router, createCallerFactory, context, +config, sourceLayout, devServerHost, telemetry?, icon?, viewColumn? }`). Add + `src/host/openWebview.ts` implemented as `return new WebviewController(...)`. + Default `telemetry` to `consoleProcedureLogger`. Add tests for the factory and + the new constructor shape. +- Commit: `feat(webview-ext)!: add openWebview factory and options-bag WebviewController`. +- Done when: factory opens a panel and returns a handle exposing `panel`, + `onDisposed`, `revealToForeground`, `dispose`, `isDisposed`; build + tests green. + +### Phase D — Documentation + +> **Documentation bar (no regression).** The new package docs must be **at least +> as detailed as the current `packages/vscode-ext-react-webview/README.md`**, and +> may exceed it. Treat that file as the floor: every topic it covers +> (architecture, quick start, entry points, peer dependencies, scope, and each +> Advanced subsection: sharing a single client, the webview-side error/event +> observer, push events with `TypedEventSink`, and the type-only router import +> rule) must survive into the new `README.md` and/or `ADVANCED.md`, updated to +> the new names, subpaths, and the factory front door. Reorganise freely, but do +> not lose depth. +> +> **Style.** No em dashes (`—`) and no en dashes (`–`) anywhere in the package +> docs; plain ASCII punctuation only. + +**WI-D1 — README: quick path + behind-the-scenes (SEO) + advanced link.** + +- Goal: a README that leads with the simplest path and is discoverable. +- Steps: rewrite `packages/vscode-ext-webview/README.md`: + - **Quick start (the quick path):** the factory `openWebview` four-file + example (router via `initWebviewTrpc`, open the panel, render with + `WithWebviewContext`, call `useTrpcClient`). + - **Behind the scenes (advanced, optional):** a short section that _names_ the + primitive/embedding capabilities for searchability — bring-your-own-panel, + `attachTrpc`, `connectTrpc`, framework-agnostic `./webview` client, pluggable + telemetry via `TelemetryRunner`/`ProcedureLogger`, push events via + `TypedEventSink` — each one sentence, then a prominent link to + `ADVANCED.md`. + - Entry-points table for `.`, `./host`, `./webview`, `./react`. Plain ASCII: + no em dashes and no en dashes. +- Commit: `docs(webview-ext): quick-start README with advanced signpost`. +- Done when: README compiles in a markdown linter if one runs; the quick-start + snippets reference only real, shipped symbols (cross-check against §13.4); the + README plus `ADVANCED.md` together cover every topic in the old + `vscode-ext-react-webview/README.md` with no loss of detail; a grep of the + file for `—` and `–` finds nothing. + +**WI-D2 — `ADVANCED.md`: the behind-the-scenes manual.** + +- Goal: the deep documentation the README links to. +- Steps: add `packages/vscode-ext-webview/ADVANCED.md` covering: the three tiers + and when to use each; bring-your-own-panel with `attachTrpc`; consumer-owned + tRPC instance and `createCallerFactory`; telemetry adapters with a worked + `TelemetryRunner`; the event channel; push events with `TypedEventSink`; the + framework-agnostic `connectTrpc` path; the type-only `AppRouter` import rule. +- Commit: `docs(webview-ext): add ADVANCED.md`. +- Done when: every symbol referenced exists in the package; links resolve; the + Advanced topics from the old README (share-a-single-client, error/event + observer, push events, type-only import) are all present at equal or greater + depth; a grep for `—` and `–` finds nothing. + +### Phase E — Migrate `vscode-documentdb` onto the new package + +**WI-E1 — Migrate the `_integration/` layer.** + +- Goal: `src/webviews/_integration/` uses the new package + names. +- Steps: update `appRouter.ts` and `trpc.ts` to build the router via + `initWebviewTrpc()` and wire telemetry through + `telemetryMiddlewareBody` + a DocumentDB `TelemetryRunner` adapter (wrapping + `callWithTelemetryAndErrorHandling`) instead of `createMiddleware` / + `publicProcedureWithTelemetry`. Update `WebviewControllerBase.ts` to extend the + new `WebviewController` (options-bag constructor) imported from + `@microsoft/vscode-ext-webview/host`. This is the **class-first** step (the + smallest, safest diff onto the new package); the factory adoption follows in + WI-E4. +- Commit: `chore(documentdb): migrate _integration to @microsoft/vscode-ext-webview`. +- Done when: extension builds; webviews still open and round-trip a tRPC call in + a manual smoke (or existing integration tests pass). + +**WI-E2 — Migrate consumer imports and hooks.** + +- Goal: all component/entry imports use the new subpaths and split hooks. +- Steps: update `src/webviews/index.tsx` and the `useConfiguration` / + `WithWebviewContext` imports from `.` to `./react`. Update any + `useTrpcClient` consumers to the client-first shape; add `useRpcEvents` where a + central error/observer existed. Update `src/webviews/_integration/useTrpcClient.ts`. +- Commit: `chore(documentdb): point webview consumers at ./react and split hooks`. +- Done when: extension builds; lint + jest green; grep for the old subpath + imports returns nothing. + +**WI-E3 — Flip the dependency.** + +- Goal: the extension depends on the new package only. +- Steps: in the root `package.json`, replace the `@microsoft/vscode-ext-react-webview` + dependency with `@microsoft/vscode-ext-webview`; `npm install`; ensure webpack + resolves it. Grep the whole `src/` for the old specifier and fix any stragglers. +- Commit: `chore(documentdb): depend on @microsoft/vscode-ext-webview`. +- Done when: full milestone verification (§1.4) green; `grep -r "vscode-ext-react-webview" src/` + is empty. + +**WI-E4 — Adopt the `openWebview` factory for panel controllers.** + +- Goal: the extension dogfoods the greenfield factory; construction-only + controllers become factory calls (the class path was just the safe landing). +- Steps: + - Add a consumer preset `src/webviews/_integration/openAppWebview.ts` (the + factory equivalent of `WebviewControllerBase`): a thin function that calls + `openWebview(...)` pre-filling the DocumentDB router, `createCallerFactory`, + the `TelemetryRunner`, and the bundle / dev-server layout, and returns the + `WebviewController` handle. + - For each panel controller that is **construction-only** (no instance state, + no externally-called methods beyond `panel` / `onDisposed` / + `revealToForeground` / `dispose` / `isDisposed`), replace + `class X extends WebviewControllerBase` with a factory function (e.g. + `openCollectionViewPanel(...)`) that derives config + context and calls + `openAppWebview(...)`. Update call sites (`new X(...)` becomes `openX(...)`); + the returned handle keeps `onDisposed` / `revealToForeground` working. + - Leave any genuinely **stateful / method-rich** controller on the class path + (extending the new `WebviewController`); that is a supported outcome, not a + failure. + - If nothing still extends `WebviewControllerBase` after conversion, delete + `WebviewControllerBase.ts`; otherwise keep it for the stateful controllers. +- Commit: `chore(documentdb): adopt openWebview factory for panel controllers`. +- Done when: each converted view opens and round-trips a tRPC call; call sites + use the factory; `grep` for `extends WebviewControllerBase` matches only + intentionally-stateful controllers (or nothing); scoped tests + lint green. + +### Phase F — Delete the old package + +**WI-F1 — Remove `vscode-ext-react-webview`.** + +- Goal: the old package is gone with zero dangling references. +- Steps: delete `packages/vscode-ext-react-webview/`. Remove any explicit + workspace/tsconfig path references. `npm install`. Grep the entire repo + (excluding this plan, the design doc, and the migration manual) for both the + package name and the old folder path; resolve any hits. +- Commit: `chore: remove deprecated @microsoft/vscode-ext-react-webview package`. +- Done when: full milestone verification (§1.4) green; `grep -rn "vscode-ext-react-webview" .` + returns only historical mentions in `docs/ai-and-plans/`; no build/test + references the old package. (Not yet published, so no npm delisting needed.) + +### Phase G — Internal migration manual + +**WI-G1 — Draft the migration manual (unlinked, internal).** + +- Goal: a reusable record of the before/after for our team and as a template for + the Cosmos PR. +- Steps: create `docs/ai-and-plans/webview-ext-migration-manual.md`. Include: + the old-to-new **rename map** (package, folder, subpaths, symbols); the + telemetry-model migration (bound middleware to `TelemetryRunner` adapter); the + hook split with before/after call sites; and **two migration paths for a + panel-owning consumer**, each with before/after code: + - **Path A (class):** point at the new package and have the controller extend + the new `WebviewController` (options-bag constructor). Lowest churn; keeps + stateful / method-rich controllers as classes. + - **Path B (factory):** replace a construction-only controller with + `openWebview(...)` via a consumer preset (`openAppWebview`); show the + `new X()` to `openX(...)` call-site change and that the returned handle + preserves `onDisposed` / `revealToForeground`. + Document that A and B can be **sequenced** (land A first for a safe, minimal + diff, then convert to B, as this plan does) or done in one step, and how to + choose (construction-only goes to B; stateful stays on A). Add a short + **embedders** note pointing at the `attachTrpc` bring-your-own-panel path for + consumers like vscode-cosmosdb. Do **not** link it from any index, README, or + the design doc; it is internal. +- Commit: `docs: add internal webview-ext migration manual`. +- Done when: the file exists, is self-contained, covers both paths, and is not + referenced by any other tracked file (grep for its filename returns only + itself). + +--- + +## 4. Definition of Done (final checklist the agent must verify) + +Declare completion only when **all** of the following hold: + +1. `packages/vscode-ext-webview/` exists; `package.json` `name` is + `@microsoft/vscode-ext-webview`, `version` is `0.9.0-preview`. +2. The package exposes exactly the subpaths `.`, `./host`, `./webview`, `./react`, + and the public names match **§13.4** exactly (factory `openWebview`, class + `WebviewController`, `attachTrpc`, `connectTrpc`, `createEventChannel`/ + `RpcEventChannel`, `initWebviewTrpc`, `vscodeLink`, `errorLink`, + `loggingMiddlewareBody`/`telemetryMiddlewareBody`/`ProcedureLogger`/ + `TelemetryRunner`, `useTrpcClient`/`useRpcEvents`). +3. Retired symbols (`createMiddleware`, `publicProcedureWithTelemetry`, + `TelemetryContext`, the old `useTrpcClient` tuple return) appear nowhere. +4. `./` imports no `vscode` and no `react`; `./webview` imports no `react`. +5. The package builds (`tsc`) and all package tests pass. +6. `packages/vscode-ext-react-webview/` is deleted; repo-wide grep for the old + name yields only historical `docs/ai-and-plans/` mentions. +7. The extension is fully migrated; `grep -rn "vscode-ext-react-webview" src/` + is empty. Construction-only panel controllers use the `openWebview` factory; + any controller kept as a class is intentionally stateful. +8. Final full verification green (§1.4 milestone set): `npm run l10n` (if + strings changed), `npm run prettier-fix`, `npm run lint`, + `npx jest --no-coverage`, `npm run build`. +9. Package `README.md` (quick path + behind-the-scenes + ADVANCED link) and + `ADVANCED.md` exist, reference only shipped symbols, together cover every + topic in the old `vscode-ext-react-webview/README.md` at equal or greater + detail, and contain no em dashes or en dashes. +10. `docs/ai-and-plans/webview-ext-migration-manual.md` exists, internal/unlinked. +11. Every work item is its own commit; all fixes are follow-up commits; no + amends, no force-push, no history rewrite. +12. No `TDD:` test was modified to pass; if any `TDD:` test failed, the operator + was consulted. +13. The Progress log (§8) has one entry per completed work item, each with a + summary, the checks that ran, and a Deviations field ("None" or a documented + deviation with alternatives and rationale). + +If any item cannot be reached and your confidence in resolving it is below 80%, +**stop and request an operator decision** rather than aborting or forcing. + +--- + +## 5. Commit ledger (expected order) + +``` +(preflight) merge origin/main into the working branch +chore(webview-ext): scaffold vscode-ext-webview as a copy +chore(webview-ext): rename package to @microsoft/vscode-ext-webview@0.9.0-preview +refactor(webview-ext): introduce shared entry (wire types, TypedEventSink, BaseRouterContext) +refactor(webview-ext): introduce host entry +refactor(webview-ext): split framework-agnostic webview entry from react entry +chore(webview-ext): export ./host, ./webview, ./react subpaths +feat(webview-ext): add initWebviewTrpc typed-init helper +feat(webview-ext)!: replace bound telemetry with middleware bodies + adapters +refactor(webview-ext): extract attachTrpc dispatcher from WebviewController +feat(webview-ext): add createEventChannel and refit errorLink as a shim +feat(webview-ext): add connectTrpc webview client factory +feat(webview-ext)!: split useTrpcClient (client) and useRpcEvents (channel) +feat(webview-ext)!: add openWebview factory and options-bag WebviewController +docs(webview-ext): quick-start README with advanced signpost +docs(webview-ext): add ADVANCED.md +chore(documentdb): migrate _integration to @microsoft/vscode-ext-webview +chore(documentdb): point webview consumers at ./react and split hooks +chore(documentdb): depend on @microsoft/vscode-ext-webview +chore(documentdb): adopt openWebview factory for panel controllers +chore: remove deprecated @microsoft/vscode-ext-react-webview package +docs: add internal webview-ext migration manual +``` + +Follow-up fix commits (`fix(...)`) are inserted as needed; they are never folded +back into the commits above. + +--- + +## 6. Appendix A — file mapping (old to new) + +| Old (`vscode-ext-react-webview/src`) | New (`vscode-ext-webview/src`) | +| ------------------------------------------------ | ---------------------------------------------------------------------------- | +| `extension-server/BaseRouterContext.ts` | `shared/BaseRouterContext.ts` | +| `extension-server/TypedEventSink.ts` | `shared/TypedEventSink.ts` | +| wire types inside `webview-client/vscodeLink.ts` | `shared/wireProtocol.ts` | +| `extension-server/trpc.ts` | split: `shared/initWebviewTrpc.ts` + `host/middleware/*` | +| `extension-server/WebviewController.ts` | `host/WebviewController.ts` + `host/attachTrpc.ts` + `host/openWebview.ts` | +| `webview-client/vscodeLink.ts` | `webview/vscodeLink.ts` | +| `webview-client/errorLink.ts` | `webview/errorLink.ts` (+ `webview/events.ts`, `webview/connectTrpc.ts` new) | +| `webview-client/WebviewContext.tsx` | `react/WebviewContext.tsx` | +| `webview-client/useConfiguration.ts` | `react/useConfiguration.ts` | +| `webview-client/useTrpcClient.ts` | `react/useTrpcClient.ts` + `react/useRpcEvents.ts` | +| `src/index.ts`, `src/server.ts` | `src/index.ts`, `src/host.ts`, `src/webview.ts`, `src/react.ts` | + +## 7. Appendix B — operator-gate triggers (confidence < 80%) + +Stop and ask when: a `TDD:` test fails; the telemetry adapter cannot wrap +`callWithTelemetryAndErrorHandling` without changing event-name semantics; a +subpath split forces a `vscode` or `react` import into a side-agnostic entry that +you cannot resolve; the extension fails a manual webview smoke after E1/E2/E4; or any +checkpoint in §Definition of Done remains red after one bounded follow-up commit. + +--- + +## 8. Progress log (resumability journal) + +Append one entry per work item, in order, as you complete it (§1.7). Include the +entry in that work item's commit. On restart, this section plus +`git --no-pager log --oneline` tell you exactly where to resume. + +**Entry template:** + +``` +### WI- - (<YYYY-MM-DD>) +- Status: done | partial (<reason>) +- Summary: <1 to 3 lines on what was implemented or changed> +- Checks: <what ran, e.g. lint + scoped tests green; or milestone full run green> +- Deviations: None. + (If any: what changed vs the plan; why; alternatives considered; why this one.) +- Subagent: none | <task delegated and the one-line result> +``` + +**Entries (append below this line):** + +<!-- WI-A0 onward --> + +### WI-A0 - Preflight: start from the latest `main` (2026-06-30) + +- Status: done +- Summary: Fetched origin and merged `origin/main` into the working branch + `dev/tnaum/webview-api-refinements` (fast-forward, branch was 88 behind / 0 + ahead, so no merge commit). Ran `npm install`. +- Checks: `npm install` ok; `npm run lint` clean (only the pre-existing + `webpack.config.views.js` eslint-env warning); `npm run build` green on the + synced base. Base equals `origin/main`, already CI-green, so the full Jest + suite was not re-run here. +- Deviations: None. (Fast-forward produced no merge commit, which the plan + explicitly allows; this WI-A0 entry is folded into WI-A1's commit per + the plan's exception.) +- Subagent: none. + +### WI-A1 - Copy the package into the new folder (2026-06-30) + +- Status: done +- Summary: Copied `packages/vscode-ext-react-webview/` to + `packages/vscode-ext-webview/` (source only; `dist/` and + `tsconfig.tsbuildinfo` dropped from the copy). The old package is untouched. +- Checks: `npm install` ok (both packages linked); new package `npm run build` + green; full repo `npm run build` green (extension still builds against the + old package); new package Jest 35/35 green; `npm run lint` clean. +- Deviations: Set the new package's `name` to the final + `@microsoft/vscode-ext-webview` in this WI instead of waiting for WI-A2. + Why: npm rejects two workspaces with the same name (`EDUPLICATEWORKSPACE`), + so the copy cannot install (an explicit WI-A1 "Done when") while it shares + the old name. Alternatives considered: (a) a throwaway placeholder name in + WI-A1 then rename in WI-A2 - rejected as needless churn since the final name + is known and stable; (b) merging WI-A1 and WI-A2 - rejected to preserve the + one-WI-one-commit rule. WI-A2 still performs the rest of the rebrand + (version, description, `repository.directory`). +- Subagent: none. + +### WI-A2 - Rebrand package metadata (2026-06-30) + +- Status: done +- Summary: Set `version` to `0.9.0-preview`, updated `repository.directory` to + `packages/vscode-ext-webview`, and rewrote `description` to reflect the + layered "transport + optional panel facade + framework-agnostic webview + client + optional React hooks" shape (dropped the React-centric wording). + `name` was already finalized in WI-A1. `exports` left for WI-B4. +- Checks: `npm install` ok; `npm ls @microsoft/vscode-ext-webview` resolves to + `0.9.0-preview`; new package `npm run build` green; new package Jest 35/35; + `npm run lint` clean; full repo `npm run build` green (old package and + extension still build). +- Deviations: None. +- Subagent: none. + +### WI-B1 - Create the shared entry and move shared code (2026-06-30) + +- Status: done +- Summary: Created `src/shared/` and moved `BaseRouterContext.ts`, + `TypedEventSink.ts`, and `TypedEventSink.test.ts` there (via `git mv`). + Extracted the wire-protocol types (`StopOperation`, + `VsCodeLinkRequestMessage`, `VsCodeLinkResponseMessage`) from `vscodeLink.ts` + into `src/shared/wireProtocol.ts`. Added `src/shared/index.ts` and repointed + `src/index.ts` (the `.` entry) at the shared surface. Fixed imports in + `vscodeLink.ts` (imports + re-exports the wire types from shared), + `WebviewController.ts`, `extension-server/trpc.ts`, and + `extension-server/index.ts`. +- Checks: new package `npm run build` green; new package Jest 35/35 green; + `npm run lint` clean; grep confirms no `vscode`/`react`/host imports in the + `src/shared/` subtree and `src/index.ts` only re-exports `./shared`. +- Deviations: Relocated the `TelemetryContext` type definition from + `extension-server/trpc.ts` into `shared/BaseRouterContext.ts` (its only shared + consumer), so `trpc.ts` now imports it from shared. Why: `BaseRouterContext` + (shared) carries a `telemetry?: TelemetryContext` field; keeping the type in + `trpc.ts` would force a shared->host import once `trpc.ts` moves to `host/` in + WI-B2. Co-locating it in shared keeps the dependency direction correct + (host -> shared) and is consistent with the plan's "move shared code" intent. + Alternative considered: leave the type in `trpc.ts` and accept a transitional + reverse import - rejected as architecturally backwards even temporarily. + `TelemetryContext` is still slated for retirement in WI-C2. +- Subagent: none. + +### WI-B2 - Create the host entry and move host code (2026-06-30) + +- Status: done +- Summary: Created `src/host/` and moved `WebviewController.ts` and `trpc.ts` + there (via `git mv`; their `../shared/...` and `./trpc` imports stayed valid + since `host/` sits at the same depth as the old `extension-server/`). Added + `src/host/index.ts` (host barrel) and `src/host.ts` (the `./host` entry). + Fully dissolved `src/extension-server/` (its `index.ts` removed; the folder is + gone). Converted `src/server.ts` into a transitional shim re-exporting + `./host` + `./shared` so the legacy `./server` export keeps resolving until + WI-B4 rewires the exports map. +- Checks: new package `npm run build` green (`dist/host.js` + `dist/host.d.ts` + emitted, so the `./host` entry compiles and resolves at the file level); new + package Jest 35/35 green; `npm run lint` clean. +- Deviations: Kept `src/server.ts` as a temporary compatibility shim rather than + deleting it now. Why: the package.json `exports` map still points `./server` + at `dist/server.js` until WI-B4; deleting `server.ts` in this WI would leave a + dangling export target. The shim is removed in WI-B4 with the exports rewire. + Alternative considered: rewire `exports` early here - rejected because the plan + explicitly reserves the exports map for WI-B4. +- Subagent: none. + +### WI-B3 - Split framework-agnostic webview entry from react entry (2026-06-30) + +- Status: done +- Summary: Created `src/webview/` (moved `vscodeLink.ts`/`errorLink.ts` and their + tests) and `src/react/` (moved `WebviewContext.tsx`, `useConfiguration.ts`, + `useTrpcClient.ts`) via `git mv`. Dissolved `src/webview-client/`. Added + `src/webview/index.ts` + `src/react/index.ts` barrels and the `src/webview.ts` + - `src/react.ts` entries. Repointed `react/useTrpcClient.ts` at + `../webview/errorLink`, `../webview/vscodeLink`, and the wire types from + `../shared/wireProtocol`. +- Checks: new package `npm run build` green (all four entry `.js` emitted: + `index`, `host`, `webview`, `react`); grep confirms no `react`/`react-dom`/ + `vscode-webview` imports in `src/webview/`; new package Jest 35/35 green; + `npm run lint` clean. +- Deviations: None. +- Subagent: none. + +### WI-B4 - Wire the exports map and optional React peer (2026-06-30) + +- Status: done +- Summary: Rewrote `exports` for the four subpaths (`.`, `./host`, `./webview`, + `./react`), each with `types` + `default` pointing at `dist/*.{d.ts,js}`. + Updated `typesVersions` for `host`/`webview`/`react`. Marked `react` as an + optional peer in `peerDependenciesMeta` (kept `@trpc/*` peers and the optional + `vscode-webview` peer). Removed the legacy `./server` export and deleted the + `src/server.ts` shim. +- Checks: clean `npm run build` emits all four entry `.js` + `.d.ts`; a scratch + type-only import of each subpath compiles under `module commonjs` / + `moduleResolution node` (the resolution the extension uses); new package Jest + 35/35 green; `npm run lint` clean; full repo `npm run build` green (old + package and extension still build). +- Deviations: Changed the four entry files to re-export `./<folder>/index` + explicitly (e.g. `export * from './host/index'`) instead of `./<folder>`. Why: + the emitted `dist/host.d.ts` (entry) is a sibling of the `dist/host/` folder, + and node10 resolution resolves a bare `./host` to the sibling FILE + (`host.d.ts`, i.e. itself) rather than the directory's `index.d.ts`, so every + subpath re-export resolved to an empty self-reference and exposed no members + (caught by the scratch-import check). The explicit `/index` disambiguates to + the folder barrel. Alternative considered: rename the impl folders to avoid the + file/dir name clash - rejected because the plan fixes the folder names + (`host/`, `webview/`, `react/`). This is part of WI-B4's "subpaths resolve" + goal, so it is not a separate follow-up commit. +- Subagent: none. + +### WI-C1 - initWebviewTrpc typed-init helper (2026-06-30) + +- Status: done +- Summary: Added `src/shared/initWebviewTrpc.ts`: a generic + `initWebviewTrpc<TContext extends BaseRouterContext>()` returning + `{ router, publicProcedure, createCallerFactory, middleware }` bound to the + consumer's context type (via `initTRPC.context<TContext>().create()`), plus a + default `BaseRouterContext` instance backing convenience `router` / + `publicProcedure` / `createCallerFactory` re-exports. Exported + `initWebviewTrpc`, `router`, `publicProcedure` (and the `WebviewTrpc` type) + from the shared `.` barrel. Added `initWebviewTrpc.test.ts`. +- Checks: new package `npm run build` green; Jest 38/38 (3 new) green - the + typed-init test reads `ctx.workspaceRoot` / `ctx.requestCount` with NO cast + and compiles under ts-jest, proving context inference; grep confirms shared + stays side-agnostic; `npm run lint` clean. +- Deviations: Left `host/trpc.ts` untouched, so the package transitionally holds + two default tRPC instances (the new shared default and the legacy one in + `host/trpc.ts`). Why: WI-C1's scope is strictly "add initWebviewTrpc + re-export + router/publicProcedure + tests"; the legacy `host/trpc.ts` is retired/reshaped + in WI-C2 (telemetry) and WI-C3 (attachTrpc default caller factory), at which + point the host re-points to the single shared default instance and drops its + duplicate `router`/`publicProcedure`. No consumer uses the new package's + router builders yet, so the transient duplication is inert. Alternative + considered: re-point `host/trpc.ts` now - rejected as WI-C2/C3 scope and it + would entangle the (about-to-be-deleted) legacy telemetry middleware. +- Subagent: none. + +### WI-C2 - Telemetry middleware bodies + adapters; retire old model (2026-06-30) + +- Status: done +- Summary: Added `src/host/middleware/` with `types.ts` (`ProcedureType`, + `MiddlewareResultLike`, `ProcedureErrorLike`, `ProcedureInvocation`, + `getInvocationSignal`), `loggingMiddleware.ts` (`ProcedureLogger`, + `ProcedureLogEntry`, `consoleProcedureLogger`, `loggingMiddlewareBody`), + `telemetryMiddleware.ts` (`ProcedureTelemetry`, `TelemetryRunner`, + `telemetryMiddlewareBody`), and an `index.ts` barrel. Removed the retired + telemetry model: deleted `host/trpc.ts` (split per the appendix into + `shared/initWebviewTrpc.ts` + `host/middleware/*`), dropped `createMiddleware`, + `publicProcedureWithTelemetry`, `defaultTrpcToTelemetry`, the package + `WithTelemetry` type, and the named `TelemetryContext` type (its shape is now + inline on `BaseRouterContext.telemetry`). Re-pointed `WebviewController` and + `host/index.ts` at the shared default tRPC instance (unifying the two default + instances from WI-C1). Added body tests wired onto a real `initWebviewTrpc` + instance. +- Checks: build green; Jest 44/44 (6 new) green - the bodies are exercised via a + real `publicProcedure.use(...)` over success / error / aborted paths, proving + instance-agnosticism and structural type compatibility with tRPC; word-boundary + grep confirms no `createMiddleware` / `publicProcedureWithTelemetry` / + `TelemetryContext` / `WithTelemetry` symbols remain in `src/`; `npm run lint` + clean. +- Deviations: (1) The telemetry/logging split keeps the reusable orchestration + (timing, abort detection, standard result properties) in the package bodies and + the integration-specific scope in the consumer's `TelemetryRunner` (which wraps + `callWithTelemetryAndErrorHandling`); the body injects the runner's bag as + `ctx.telemetry`. This realizes "middleware bodies + adapters" from the plan; the + exact runner signature (`run(invocation, execute)`) is an implementation choice + the plan left open. (2) `BaseRouterContext.telemetry` keeps an inline + `{ properties; measurements }` shape (no named `TelemetryContext` export) rather + than being removed, to preserve the documented field shape and minimize the + E1 migration delta; the consumer still re-types it. (3) Two doc comments + reference `callWithTelemetryAndErrorHandling` / `ITelemetryContext` (azext-utils + identifiers) which contain the substrings "WithTelemetry"/"TelemetryContext"; + a word-boundary grep is required to audit retired symbols. Alternatives + considered: removing the `telemetry` field entirely - deferred since the + consumer overrides its type anyway and removal adds migration churn. +- Subagent: none. + +### WI-C3 - Extract attachTrpc dispatcher from WebviewController (2026-06-30) + +- Status: done +- Summary: Added `src/host/attachTrpc.ts` exporting + `attachTrpc(panel, context, router, callerFactory = defaultCreateCallerFactory)` + which returns `{ disposable, activeOperations, activeSubscriptions }`. It owns + the full webview message dispatch that previously lived inside + `WebviewController`: `handleDefaultMessage` (query/mutation), the per-operation + `AbortController` tracking in `activeOperations`, subscription streaming via + `handleSubscriptionMessage` + module-level `toAsyncIterator`, `subscription.stop` + via `handleSubscriptionStopMessage`, `abort` via `handleAbortMessage`, the + disposed-guarded `safePostMessage`, and `wrapInTrpcErrorMessage`. It also exports + `ActiveSubscription`, `WebviewCallerFactory`, and `AttachTrpcResult`. The module + imports `Disposable` / `WebviewPanel` from `vscode` as TYPE-only, so it carries + no runtime `vscode` dependency and runs under the node/jsdom jest env. Slimmed + `WebviewController`: `setupTrpc(context)` now just calls + `attachTrpc(this._panel, context, this._options.appRouter)` and registers the + returned disposable; removed the `_activeOperations` / `_activeSubscriptions` + fields and all per-message handler methods; `dispose()` fires `onDisposed` and + disposes registered disposables (attachTrpc's disposable aborts in-flight ops and + aborts/returns subscriptions). `host/index.ts` now re-exports `attachTrpc` + its + types and `AnyRouter` (from `@trpc/server`). Added `src/host/attachTrpc.test.ts` + (9 tests) driving a stub `WebviewPanel`. +- Checks: `npm run lint` clean (added the missing + `@typescript-eslint/no-unsafe-assignment` to the two dispatcher procedure-lookup + disable comments that were carried over verbatim from the original controller); + Jest 53/53 (9 new) green; package `tsc --noEmit` clean; retired-symbol + word-boundary grep (`createMiddleware` / `publicProcedureWithTelemetry` / + `TelemetryContext`) clean; `npm run build` previously green for this refactor. +- Deviations: (1) The `subscription.stop` test asserts the reliable synchronous + facts (the `activeSubscriptions` entry is removed and the per-operation + `AbortController` is aborted) rather than asserting that `iterator.return()` + releases a consumer parked in `for await`. An empirical Node probe confirmed that + an async generator's `return()` does NOT propagate into a generator parked at its + inner `await sink.next()`; the parked consumer is actually released when the + producer calls `sink.close()`. That reliable path is covered by the separate + "completes a subscription when its event sink closes" test. This matches the + pre-existing `WebviewController` behavior, so there is no behavior change. + (2) The two leaf test resolvers that read `ctx.signal` annotate their opts as + `{ ctx: BaseRouterContext }` to keep typed-linting stable across the per-file and + whole-repo ESLint programs (the `vscode` type-only import in the test can degrade + cross-file inference); this does not affect production routers. +- Subagent: none. + +### WI-C4 - Event channel + errorLink shim (2026-06-30) + +- Status: done +- Summary: Added `src/webview/events.ts`. `createEventChannel()` returns an + `EventChannel` that implements both the observe side (`RpcEventChannel`: + `onSuccess` / `onError` / `onAborted`) and the publish side (`RpcEventEmitter`: + `emitSuccess` / `emitError` / `emitAborted`), plus `CallInfo`, the handler types + (`SuccessHandler` / `ErrorEventHandler` / `AbortedHandler`), and `Unsubscribe`. + Dispatch is snapshot-safe: each `emit*` iterates over a copy of its handler set, + so subscribing or unsubscribing during dispatch never corrupts the in-flight + iteration. Refit `errorLink`: factored out a general publishing link + `eventLink(emitter)` that publishes query/mutation outcomes + (`emitSuccess` on next; `emitAborted` when `op.signal.aborted`, else `emitError` + on error), skips subscriptions, and re-emits value/error/complete down the chain + unchanged. `errorLink(onError)` is now a thin shim: it owns a private channel, + bridges that channel's `onError` to the callback, and returns `eventLink(channel)`. + Exported the channel primitive from the `./webview` barrel. Added + `events.test.ts`. +- Checks: Jest 59/59 (7 new in `events.test.ts`) green; `errorLink`'s 7 existing + tests unchanged and green (the refit preserves query/mutation error forwarding, + subscription skipping, non-`Error` normalization, and success/complete + pass-through); package `tsc --noEmit` clean; `npm run lint` clean. +- Deviations: (1) §13 lists only `vscodeLink` / `errorLink` / `createEventChannel` + on `./webview`, so the general publisher `eventLink(emitter)` is exported from the + `errorLink` module but NOT re-exported from the `./webview` barrel; `connectTrpc` + (WI-C5) consumes it directly. This keeps the public subpath surface equal to the + design's set. (2) Abort vs error classification uses `op.signal?.aborted` (true at + error time means a cancel) rather than sniffing the tRPC error shape; this + realizes the design's "aborts separated from errors" while keeping `errorLink`'s + existing tests green (their ops use `signal: null`, so they classify as errors). + (3) `errorLink`'s pre-existing "does not surface subscription errors" behavior is + preserved inside `eventLink`, consistent with the design framing that the channel + observes query/mutation outcomes. +- Subagent: none. + +### WI-C5 - connectTrpc webview client factory (2026-06-30) + +- Status: done +- Summary: Added `src/webview/connectTrpc.ts`. + `connectTrpc<TRouter>(vscodeApi, options?)` creates an event channel, wires the + default transport (`send` via `vscodeApi.postMessage`; `onReceive` via + `window.addEventListener('message', ...)` with the `id` type-guard, lifted from + the React hook), assembles a tRPC client with + `[loggerLink(), eventLink(channel), vscodeLink({ send, onReceive })]`, and returns + `{ client, events }` where `events` is the observe-only `RpcEventChannel`. + `options.onError` subscribes to `channel.onError`. Added the supporting types + `VsCodeApiLike` (structural `{ postMessage }`), `ConnectTrpcOptions`, and + `ConnectTrpcResult`. Exported `connectTrpc` + its types from the `./webview` + barrel. Added `connectTrpc.test.ts`. +- Checks: Jest 63/63 (4 new) green - a query is driven end-to-end through + `connectTrpc` and observed via `events.onSuccess`; the error path surfaces + `events.onError` and the `onError` option; an already-aborted signal surfaces + `events.onAborted` (and not `onError`); package `tsc --noEmit` clean; + `npm run lint` clean. +- Deviations: (1) `connectTrpc` composes `eventLink(channel)` (the full publisher + from WI-C4), not `errorLink`, so the returned channel surfaces success / error / + aborted (`errorLink` only bridges errors). The plan named "errorLink" loosely; + `eventLink` is what realizes `{ client, events }` per §13. (2) The package jest env + is `node` (no DOM) and `jest-environment-jsdom` is not installed at the repo root, + so `connectTrpc.test.ts` installs a minimal `window` stub + (`addEventListener` / `removeEventListener` for `'message'`) plus an echoing fake + `vscodeApi` that replies with the request's own id; this exercises the real + `vscodeLink` round-trip without jsdom. (3) `loggerLink` stays in the default link + chain to preserve the prior React-hook behavior; it logs the error/abort cases to + the console during those tests (expected, non-failing). +- Subagent: none. + +### WI-C6 split useTrpcClient (client) and useRpcEvents (channel) + +- Commit: `feat(webview-ext)!: split useTrpcClient (client) and useRpcEvents (channel)`. +- What: split the single React hook into two, both backed by one memoised + connection per webview. Added `react/connection.ts` (React-free) exporting + `getWebviewConnection(vscodeApi)`, which lazily builds and caches a + `connectTrpc(vscodeApi)` result in a module-level `WeakMap` keyed by the + `vscodeApi` object. Rewrote `react/useTrpcClient.ts` so `useTrpcClient()` now + returns the client directly (was `{ trpcClient }`); dropped the `onError` + option and the `UseTrpcClientOptions` type. Added `react/useRpcEvents.ts` + exporting `useRpcEvents()`, returning the shared channel + (`onSuccess` / `onError` / `onAborted`). Updated the `./react` barrel to export + `useRpcEvents` and to stop exporting `UseTrpcClientOptions`. Added + `react/connection.test.ts`. +- Breaking: `useTrpcClient()` return shape changed (client, not `{ trpcClient }`) + and its `onError` option is gone; per-call error observation moves to + `useRpcEvents().onError`. The two hooks now share one connection per webview + rather than each component holding its own client (§13.4). +- Verification: both hooks resolve through `getWebviewConnection`, so the new + test asserts identity stability - two calls with the same `vscodeApi` return + the same `{ client, events }` (and the same `.client` / `.events`), while + distinct `vscodeApi` objects get independent connections. Package suite 65 + tests across 10 suites green; package `tsc --noEmit` clean; whole-repo + `npm run lint` clean. +- Deviations: the hooks themselves are not unit-tested directly - they cannot + run outside React in the package's `node` jest env (no RTL/jsdom), so the + identity-stability contract is verified against the React-free + `getWebviewConnection` helper the hooks delegate to. Each hook body is a thin + `useContext` + delegate wrapper. +- Subagent: none. + +### WI-C7 - Factory openWebview + options-bag WebviewController (2026-06-30) + +- Status: done (Phase C milestone). +- Summary: reshaped `WebviewController` to a single options-bag constructor + (`new WebviewController({ extensionContext, title, viewType, router, +createCallerFactory?, context, config, sourceLayout, devServerHost?, +telemetry?, icon?, viewColumn? })`) and added `host/openWebview.ts` - + `openWebview(extensionContext, options)` returns `new WebviewController({ +extensionContext, ...options })`. The constructor now wires tRPC itself + (`setupTrpc(options.context)` calls `attachTrpc` with the injected + `createCallerFactory` and the dispatch logger). To deliver "console telemetry + for free", `attachTrpc` gained an optional `logger?: ProcedureLogger` that + logs one structured entry per completed query, mutation, and subscription; + the controller defaults it to `consoleProcedureLogger`. Exported `openWebview` + from the `./host` barrel. Added `host/openWebview.test.ts` (6 tests) and 4 new + `attachTrpc.test.ts` logging tests. Added a runtime `vscode` stub at + `src/testing/vscodeStub.ts` wired via the package jest `moduleNameMapper` + (type-checking still uses `@types/vscode`). +- Checks: full milestone run green - package `tsc --noEmit` clean; package Jest + 75/75 across 11 suites; whole-repo `npm run lint` clean; whole-repo + `npx jest --no-coverage` 2606/2606 across 149 suites; `npm run build` green. + `npm run l10n` not run: no user-facing localized strings were added or changed + (the controller HTML and dispatch logs are not localized). +- Deviations: + (1) Telemetry wiring - chose active dispatch-level logging (extend + `attachTrpc` with an optional `ProcedureLogger`) over a store-only seam. Why: + the design doc's "console telemetry for free" / "wires the default console + logger" promise requires the controller to actually log; a defaulted + `telemetry` option that did nothing would be a dead option. The dispatch + logger is a separate sink from the middleware-body telemetry (Phase E analytics + via `telemetryMiddlewareBody`), so the two do not double-count. The plan's + WI-C7 steps did not enumerate touching `attachTrpc`; this is the minimal way to + honor the documented behavior, and the WI-C3 dispatch logic is otherwise + unchanged (logging is purely additive and gated on a provided logger). + (2) Dropped `isBundled` from the options bag (the plan's listed fields omit + it); the bundled-vs-dev layout is now derived from + `extensionContext.extensionMode === vscode.ExtensionMode.Production`, matching + the `isProduction` value the HTML template already computed. + (3) Testing the host facade needs a runtime `vscode`; added a stub under + `src/testing/` (NOT `src/__mocks__/`) deliberately - a `__mocks__/vscode` + file is registered as a global jest manual mock and collided with the + extension's own vscode mock in the root multi-project run (surfaced as + `vscode.l10n.t is not a function` across 37 extension suites). Keeping the stub + outside `__mocks__/` and resolving it only through the package-scoped + `moduleNameMapper` isolates it to this package's jest project. +- Subagent: none. + +### WI-D1 - README: quick-start + behind-the-scenes signpost (2026-06-30) + +- Status: done. +- Summary: rewrote `packages/vscode-ext-webview/README.md` from the old + copied-over `vscode-ext-react-webview` README to the new package. Leads with a + factory-first four-file quick start (`initWebviewTrpc` router, `openWebview` + to open the panel, `WithWebviewContext` to render, `useTrpcClient` returning + the client directly). Updated the architecture diagram to ASCII and to the new + names (`openWebview` / `attachTrpc` / subpaths). Added a "Behind the scenes + (advanced, optional)" section that names the primitives for searchability + (`attachTrpc` bring-your-own-panel, `connectTrpc` + `createEventChannel` + framework-agnostic client, `errorLink` observer, `telemetryMiddlewareBody` + + `TelemetryRunner` / `loggingMiddlewareBody` + `ProcedureLogger` telemetry, + `TypedEventSink` push events, type-only `AppRouter` import) with prominent + links to `ADVANCED.md`. Added a four-row entry-points table for `.`, `./host`, + `./webview`, `./react` with side, imports, and key exports, plus an import + cheat-sheet. Refreshed "What's inside", peer-deps (React noted as only for + `./react`), scope, starter-kit, and status (`0.9.0-preview`). The deep Advanced + subsections and FAQ from the old README move to `ADVANCED.md` in WI-D2; the + README signposts them. +- Deviations: forward-links to `ADVANCED.md` which is authored next in WI-D2 + (same phase) - the link resolves once D2 lands. Diagram and tables use plain + ASCII (`+ - | < >`) rather than box-drawing characters to honor the "plain + ASCII punctuation only" documentation bar. +- Checks: cross-checked every referenced symbol against the shipped barrels + (`shared/host/webview/react` index files) and §13.4 - all real; no retired + symbol (`createMiddleware`, `publicProcedureWithTelemetry`, `TelemetryContext`) + present; no `vscode-ext-react-webview` / `./server` references (only + `@trpc/server` the npm package); `grep -nP "[\x{2013}\x{2014}]"` finds no + em/en dashes; whole-repo `npm run lint` clean (docs-only change). Full milestone + build + jest deferred to the end of Phase D (after WI-D2). +- Subagent: none. + +### WI-D2 - ADVANCED.md: the behind-the-scenes manual (2026-06-30) [MILESTONE end D] + +- Status: done (Phase D milestone). +- Summary: added `packages/vscode-ext-webview/ADVANCED.md`, the deep manual the + README signposts. Sections: the three tiers and when to use each (front door / + panel primitive / transport primitive); bring-your-own-panel with `attachTrpc` + (return shape `disposable` / `activeOperations` / `activeSubscriptions`); the + consumer-owned tRPC instance and `createCallerFactory`; telemetry adapters + (worked `ProcedureLogger` + `loggingMiddlewareBody` and a `TelemetryRunner` + + `telemetryMiddlewareBody` Application-Insights example, noting the two sinks do + not double-count); the webview event channel (`useRpcEvents` / + `createEventChannel` with `onSuccess` / `onError` / `onAborted`), which replaces + the old `useTrpcClient({ onError })` observer; the framework-agnostic + `connectTrpc` path; push events with `TypedEventSink` (emit overloads, single + consumer, `close()` semantics, `iterator.return()` cleanup); the type-only + `AppRouter` import rule; and an FAQ (why tRPC, why four entry points, multiple + panels, cancellation, non-React frameworks). Every old-README Advanced topic is + preserved at equal or greater depth, updated to the new names. +- Checks: every referenced symbol verified against the shipped barrels and + §13.4; no retired symbol present; `grep -nP "[\x{2013}\x{2014}]"` finds no + em/en dashes in `ADVANCED.md`; `npm run prettier-fix` leaves both docs + unchanged (already compliant). Phase D milestone re-run green: whole-repo + `npm run lint` clean; whole-repo `npx jest --no-coverage` 2606/2606 across 149 + suites; `npm run build` green. `npm run l10n` not run: docs-only phase, no + localized strings touched. (No code changed since the WI-C7 milestone, so the + package's own 75-test suite and `tsc` outcome are unchanged from that run.) +- Deviations: none. +- Subagent: none. + +### WI-E1 - Migrate `_integration` to `@microsoft/vscode-ext-webview` (2026-06-30) [start E] + +- Status: done. +- Summary: repointed the extension's `src/webviews/_integration/` layer from the + old `@microsoft/vscode-ext-react-webview/server` onto the new package. `trpc.ts` + now builds the single tRPC instance via `initWebviewTrpc<FrameworkBaseRouterContext>()` + (destructuring `publicProcedure` / `router` / `createCallerFactory`) and replaces + the retired `createMiddleware`-based `trpcToTelemetry` with a `TelemetryRunner` + (`documentDbTelemetryRunner`) consumed through `telemetryMiddlewareBody`. The + runner keeps the exact `documentDB.rpc.${type}.${path}` event names, sets + `suppressDisplay`, and adds DocumentDB-specific error enrichment after `execute` + (`parseError`-derived `error` / `errorMessage`, plus `errorStack` / `errorCause`) + that overwrites the body's plain name/message. `appRouter.ts` and + `WebviewControllerBase.ts` import `BaseRouterContext` / `WebviewController` from + the new package; the base class adopts the options-bag `super({...})` form + (deriving bundled-vs-dev from `extensionMode`, so `ext.isBundle` / the explicit + `isBundled` flag are gone) and now accepts the per-view router context, passing + `router: appRouter` and `createCallerFactory`. The two view controllers + (`collectionViewController.ts`, `documentsViewController.ts`) build their + `trpcContext` before `super(...)` and pass it as the new argument, dropping the + separate `this.setupTrpc(...)` call (the options-bag controller auto-wires tRPC). +- Consumer ripple: `telemetryMiddlewareBody` injects `telemetry` into ctx at + runtime but, unlike the old `createMiddleware`, does not widen the static ctx + type, so `queryInsightsRouter.ts`'s direct `ctx.telemetry` accesses (Stage 1 and + Stage 2) now go through the already-declared `myCtx = ctx as WithTelemetry<RouterContext>` + narrowing. No behavior change; the same telemetry object is written at runtime. +- Checks: `npm run build` green (all packages + extension `tsc`); whole-repo + `npm run lint` clean (only the pre-existing benign `webpack.config.views.js` + eslint-env node warning); scoped `npx jest --no-coverage src/webviews` 313/313 + across 12 suites. `npm run l10n` not run: no user-facing strings changed. + `grep -nP "[\x{2013}\x{2014}]"` finds no em/en dashes in the changed files or + this entry. Old package still resolvable (both are workspace symlinks), so the + `.tsx` consumers still importing it compile until WI-E2/WI-E3. +- Deviations: extending the touched set to `queryInsightsRouter.ts` was required + to keep the extension building after the telemetry-middleware type contract + changed; it is a direct consequence of the `_integration` migration, so it + stays in this single commit. +- Subagent: none. + +### WI-E2 - Point webview consumers at ./react and split hooks (2026-06-30) + +- Status: done. +- Summary: repointed every extension-side webview import off the old + `@microsoft/vscode-ext-react-webview` main entry onto the new package's + `./react` subpath. `src/webviews/index.tsx` (`WebviewState`, `WithWebviewContext`) + and the five `useConfiguration` consumers (`CollectionView.tsx`, + `QueryInsightsTab.tsx`, `QueryEditor.tsx`, `ToolbarMainView.tsx`, + `documentView.tsx`) now import from `@microsoft/vscode-ext-webview/react`. The + local `src/webviews/_integration/useTrpcClient.ts` wrapper imports the new + `./react` `useTrpcClient` and adopts the client-first shape (returns the client + directly), so the nine consumer call sites changed from + `const { trpcClient } = useTrpcClient();` to `const trpcClient = useTrpcClient();` + (`CollectionView.tsx`, `ToolbarMainView.tsx` x2, `ToolbarViewNavigation.tsx`, + `ToolbarTableNavigation.tsx`, `QueryEditor.tsx`, `QueryInsightsTab.tsx`, + `documentView.tsx`, `QueryPlanSummary.tsx`). Two stale doc references + (`_integration/README.md` link, a `QueryInsightsTab.tsx` comment) were updated + to the new package. +- useRpcEvents: not added. The local `useTrpcClient` wrapper never exposed the + old `onError` option and no consumer installed a central webview-side error + observer, so there was nothing to migrate to `useRpcEvents`. +- Checks: `npm run build` green (all packages + extension `tsc`); whole-repo + `npm run lint` clean (only the pre-existing benign `webpack.config.views.js` + node warning); scoped `npx jest --no-coverage src/webviews` 313/313 across 12 + suites. `grep -r "vscode-ext-react-webview" src/webviews` returns nothing (the + only remaining repo matches are inside the old package itself, removed in WI-F1). + `npm run l10n` not run: no user-facing strings changed. No em/en dashes in the + changed files or this entry. +- Deviations: none. +- Subagent: none. + +### WI-E3 - Flip the dependency to @microsoft/vscode-ext-webview (2026-06-30) + +- Status: done. +- Summary: in the root `package.json`, replaced the + `@microsoft/vscode-ext-react-webview` dependency with + `@microsoft/vscode-ext-webview` (both `"*"` workspace links); ran `npm install`. + The lockfile delta is exactly the renamed root dependency plus the new + package's `peerDependenciesMeta.react.optional` entry (the new package marks + React as an optional peer). `npm` was 10.9.3, matching CI, so `npm ci` stays + valid. +- Prep commit: the milestone `npm run prettier-fix` reformatted one WI-E1 file + (`trpc.ts` host import wrapped across lines); committed separately as + `style(documentdb): wrap trpc.ts host import per prettier` (c73aa4d6) to keep + this dependency-flip commit focused, matching the WI-C7 precedent. +- Checks (full milestone, plan §1.4): whole-repo `npm run lint` clean (only the + pre-existing benign `webpack.config.views.js` node warning); whole-repo + `npx jest --no-coverage` 2606/2606 across 149 suites (5 projects); + `npm run build` green (all packages + extension `tsc`); `npm run prettier-fix` + leaves the tree clean after the style commit above. `grep -r "vscode-ext-react-webview" src/` + returns nothing (extension source no longer references the old package; only + the old package's own files under `packages/` still do, removed in WI-F1). + `npm run l10n` not run: no user-facing strings changed. No em/en dashes in the + changed files or this entry. +- Deviations: none. +- Subagent: none. + +### WI-E4 - Adopt the openWebview factory for panel controllers (2026-06-30) [MILESTONE end E] + +- Status: done. +- Summary: added `src/webviews/_integration/openAppWebview.ts`, the factory-style + counterpart to `WebviewControllerBase`. `openAppWebview` calls the framework + `openWebview(ext.context, ...)` pre-filling the DocumentDB root router + (`appRouter`), `createCallerFactory`, the bundle / dev-server layout + (`WEBVIEW_CONFIG.bundle` + `devServerHost`), and accepts per-view + `title` / `webviewName` (-> `viewType`) / `config` / `context` / `viewColumn` / + `icon`. It exports a `AppWebviewController<TConfiguration>` alias + (`WebviewController<AppRouter, TConfiguration, BaseRouterContext>`) so every + panel factory has a precise return type. The procedure-level telemetry runner + stays wired through `publicProcedureWithTelemetry` in the routers, exactly as + before, so the controller keeps the framework default procedure logger (no + behavior change from the retired `WebviewControllerBase`). +- Both panel controllers were construction-only (a single constructor, no + instance state, and call sites that only touch the handle's `onDisposed` / + `revealToForeground`), so both were converted to factory functions: + - `CollectionViewController` -> `openCollectionViewPanel(...)`; call site in + `src/commands/openCollectionView/openCollectionView.ts` switched from + `new CollectionViewController(...)` to `openCollectionViewPanel(...)` + (still uses `view.onDisposed(...)` + `view.revealToForeground()`). + - `DocumentsViewController` -> `openDocumentViewPanel(...)`; call site in + `src/commands/openDocument/openDocument.ts` switched to + `openDocumentViewPanel(...)` (still uses `view.revealToForeground(...)`). + Its `viewPanelTitleSetter` closed over `this.panel`; replaced with a small + mutable holder object (`const handle: { controller?: ... } = {}`) whose + `controller` is assigned from the `openAppWebview(...)` result, so the setter + reads `handle.controller.panel.title` at runtime. A plain `let controller` + tripped `prefer-const` (single assignment), hence the holder. +- Nothing else extended `WebviewControllerBase`, so + `src/webviews/_integration/WebviewControllerBase.ts` was deleted (`git rm`). + The lingering doc reference in `appRouter.ts` was updated to point at the + `openAppWebview` factory / `viewType` option instead. +- Checks (full milestone, plan §1.4): whole-repo `npm run lint` clean (only the + pre-existing benign `webpack.config.views.js` node warning); whole-repo + `npx jest --no-coverage` 2606/2606 across 149 suites (5 projects); + `npm run build` green (all packages + extension `tsc`); `npm run prettier-fix` + leaves the tree clean. `grep "extends WebviewControllerBase" src/` returns + nothing; the only remaining `WebviewControllerBase` mentions are the doc-prose + references in `openAppWebview.ts` (describing what it replaces). + `npm run l10n` not run: no user-facing strings changed. No em/en dashes in the + changed files or this entry. +- Deviations: both controllers converted to factories (rather than leaving one on + the class path); this was supported by the plan ("construction-only controllers + become factory calls") and let `WebviewControllerBase` be removed entirely. +- Subagent: none. + +### WI-F1 - Remove deprecated @microsoft/vscode-ext-react-webview package (2026-06-30) [MILESTONE] + +- Status: done. +- Summary: deleted `packages/vscode-ext-react-webview/` (`git rm -r`, then `rm -rf` + to clear the gitignored `dist/` that survived `git rm` and kept the directory on + disk). Removed the two explicit path references: the tsconfig project reference + (`tsconfig.json` `references[]`) and the root jest project entry + (`jest.config.js` `projects[]`). Ran `npm install`; the lockfile dropped the + package's `node_modules` symlink entry, but npm left a stale extraneous + `packages/vscode-ext-react-webview` workspace block (npm does not auto-prune + these; the repo already carried a similar pre-existing `vscode-webview-api` + extraneous block). Removed the dead block by hand and re-ran `npm install`; + `npm ci --dry-run` then validates the lockfile cleanly. +- Doc reference cleanup: `.github/skills/webview-trpc-messaging/SKILL.md` still + taught the old two-subpath API. Updated it to the new package: the key-files + table now points at `@microsoft/vscode-ext-webview` (shared), + `/host` (telemetry + `WebviewController` + `openWebview`), and `/webview` + (`vscodeLink`); the "create the controller" section now shows the + `openAppWebview` factory function instead of the deleted `WebviewControllerBase` + subclass + `this.setupTrpc()`; the registry note, the `useConfiguration` import + (`/react`), and the client-first `useTrpcClient()` call (no tuple) were all + corrected. `.github` markdown is outside the repo `prettier` glob + (`(src|test|l10n|grammar|docs|packages)/**/*.{js,ts,jsx,tsx,json}` + root), and + the file was already not prettier-formatted at HEAD, so its tables were left in + the file's existing hand-aligned style. +- Checks (full milestone, plan §1.4): whole-repo `npm run lint` clean (only the + pre-existing benign `webpack.config.views.js` node warning); whole-repo + `npx jest --no-coverage` 2571/2571 across 146 suites (4 projects) -- down from + 2606/149/5 by exactly the removed package's 35 tests / 3 suites / 1 project; + `npm run build` green; `npm run prettier-fix` leaves the in-scope tree clean. + `git grep "vscode-ext-react-webview" -- ':!docs/ai-and-plans/'` returns nothing + (the only remaining mentions are the historical plan + design doc under + `docs/ai-and-plans/`, and gitignored build output under `out/` / `dist/`). + `npm run l10n` not run: no user-facing strings changed. No em/en dashes in the + changed files or this entry. +- Deviations: hand-removed one stale extraneous lockfile block that `npm install` + would not prune (mirrors a pre-existing `vscode-webview-api` extraneous entry + the repo already shipped); validated with `npm ci --dry-run`. +- Subagent: none. + +### WI-G1 - Add internal webview-ext migration manual (2026-06-30) [MILESTONE final DoD] + +- Status: done. +- Summary: created `docs/ai-and-plans/webview-ext-migration-manual.md`, an + internal, unlinked record of the migration and a template for the parallel + vscode-cosmosdb adoption PR. It covers: the old-to-new rename map (package, + folder, the 2-subpath to 4-subpath split, and a per-symbol table including the + retired `createMiddleware` / `TelemetryContext` / `UseTrpcClientOptions` and the + new `initWebviewTrpc` / `attachTrpc` / `connectTrpc` / `openWebview`); the + telemetry-model migration (bound middleware to a consumer `TelemetryRunner` + adapter, plus the `WithTelemetry` `myCtx` narrowing now required because + `telemetryMiddlewareBody` does not widen `ctx`); the hook split with before / + after call sites (`{ trpcClient }` tuple to client-first `useTrpcClient()`, plus + `useRpcEvents`); and both panel migration paths with before / after code -- Path + A (class extends the options-bag `WebviewController`) and Path B (factory via an + `openAppWebview` preset over `openWebview`), including the holder pattern for a + panel-dependent title setter, how to choose (construction-only to B, stateful + stays on A), and that A and B can be sequenced. An embedders section documents + the `attachTrpc` bring-your-own-panel path with the exact + `{ disposable, activeOperations, activeSubscriptions }` return. +- Symbol names in the manual were verified against the shipped barrels + (`packages/vscode-ext-webview/src/{index,host/index,webview/index,react/index}.ts`) + and the real `attachTrpc` / `WebviewController` signatures, not from memory. +- Unlinked: the only tracked mentions of the filename are in this plan (the WI-G1 + step and DoD item 10), which is intrinsic specification, not an index / README / + design-doc link; no other file references it. +- Checks (full milestone, final DoD, plan §1.4): whole-repo `npm run lint` clean + (only the pre-existing benign `webpack.config.views.js` node warning); whole-repo + `npx jest --no-coverage` 2571/2571 across 146 suites (4 projects); + `npm run build` green; `npm run prettier-fix` leaves the in-scope tree clean + (`docs/**/*.md` is outside the prettier glob, which is js/ts/jsx/tsx/json). + `npm run l10n` not run: no user-facing strings changed. No em/en dashes and no + bare "MongoDB" product usage in the manual or this entry. +- Deviations: none. +- Subagent: none. + +--- + +## 9. Post-implementation refinements (2026-07-02) + +Small changes made after the work items above completed, during review of PR +#766. Recorded here so a future reviewer can see what moved after the migration +and why. None of these change the public API locked in the design doc section 13; +the one rename below keeps every exported symbol name identical. + +### 9.1 Planning docs relocated + +The three planning docs (the design doc, this plan, and the migration manual) +were moved from the flat `docs/ai-and-plans/` into +`docs/ai-and-plans/PRs/766-webview-ext-package-redesign/`, matching the existing +per-PR convention in that folder. Earlier progress-log entries (for example +WI-G1) still cite the old flat path; those are historical and were left as-is. + +### 9.2 Middleware filenames de-stuttered + +`host/middleware/loggingMiddleware.ts` became `logging.ts` and +`telemetryMiddleware.ts` became `telemetry.ts` (with their `.test.ts` siblings), +via `git mv`. Every exported symbol (`loggingMiddlewareBody`, +`telemetryMiddlewareBody`, `ProcedureLogger`, `TelemetryRunner`, and so on) is +unchanged. Why: inside a folder already named `middleware/`, the `Middleware` +filename suffix stuttered; the shorter names read cleaner while the explicit +`*Body` symbol names keep call sites self-documenting. All five path imports were +updated and the package rebuilt clean (Jest 75/75). + +### 9.3 Per-folder READMEs added to the package + +A short README was added to each `packages/vscode-ext-webview/src/` folder +(`src`, `shared`, `host`, `host/middleware`, `webview`, `react`, `testing`). Each +states the folder's side (host / webview / shared), its import path, its +contents, and the import rules it enforces (for example: `.` imports no `vscode` +or `react`; `./webview` imports no `react`). The audience is humans and coding +agents. These files are not published (only `dist` and the root README ship), so +the npm artifact is unaffected. Why: give a reader or agent an at-a-glance map of +the four-subpath layout without tracing the exports. + +### 9.4 Naming reviewed and deliberately kept + +Three names were re-examined and kept, recorded here so reviewers know they were +considered rather than overlooked: + +- `openAppWebview` (the consumer preset) was kept over `openWebviewHelper` / + `openDocumentDbWebview`. The `App` prefix mirrors `appRouter` / `AppRouter` / + `AppWebviewController`; a `Helper` suffix would carry no domain meaning. +- The thin `_integration/useTrpcClient` wrapper was kept: it pins the + `<AppRouter>` type argument (one binding point) and gives an import seam for + future cross-cutting changes. +- `WebviewRegistry` was kept: it is the required viewType-to-component dispatch + table for a single bundle serving multiple panels, and the source of the + `WebviewName` cross-side safety type. Removing it would only relocate the map + or drop the type check. + +### 9.5 Consumer docs refreshed + +`src/webviews/_integration/README.md` was brought up to date: it dropped the +deleted `WebviewControllerBase.ts`, added `trpc.ts`, moved telemetry ownership to +`trpc.ts`, refreshed the data-flow and closing notes, and expanded the file table +with a `Side` column and fuller per-file purpose. `WebviewRegistry.ts` gained a +doc comment explaining why it exists and a four-step "how to add a webview", and +its stale `WebviewName` comment (which referenced the removed `WebviewController`) +was corrected. diff --git a/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-migration-manual.md b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-migration-manual.md new file mode 100644 index 000000000..8a90c9075 --- /dev/null +++ b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-migration-manual.md @@ -0,0 +1,378 @@ +# Webview-ext migration manual (internal) + +Status: internal working note. Not linked from any index, README, or design doc. +It records the before/after of moving `@microsoft/vscode-ext-react-webview` to +`@microsoft/vscode-ext-webview`, both as a record for our team and as a template +for the parallel vscode-cosmosdb adoption PR. + +Reviewed and current as of 2026-07-02, after the post-implementation refinements +recorded in the implementation plan (section 9). The refinements did not change +any exported symbol or import path, so the rename map and code samples below +still hold verbatim. + +This is a DocumentDB extension that speaks the MongoDB-compatible wire protocol; +references below to "DocumentDB" mean the database service, and "MongoDB API" +means the wire protocol / query language. + +--- + +## 1. What changed, in one paragraph + +The old package shipped two entry points (`.` mixing the webview client and +React, and `./server` for the host) and bound its own telemetry model into the +tRPC middleware. The new package splits into four side-aware subpaths +(`.` shared, `./host`, `./webview`, `./react`), exposes the host wiring as +composable primitives (`attachTrpc`, `openWebview`, the options-bag +`WebviewController`), turns telemetry into a small adapter you provide +(`telemetryMiddlewareBody` + a `TelemetryRunner`), and splits the React hook into +a client hook (`useTrpcClient`) and an events hook (`useRpcEvents`). + +--- + +## 2. Rename map (old to new) + +### Package and folder + +| Old | New | +| ------------------------------------- | ------------------------------- | +| `@microsoft/vscode-ext-react-webview` | `@microsoft/vscode-ext-webview` | +| version `0.8.0-preview` | version `0.9.0-preview` | +| `packages/vscode-ext-react-webview/` | `packages/vscode-ext-webview/` | + +### Subpaths (2 to 4) + +| Old subpath | New subpath(s) | +| ----------------------------------- | ------------------------------------------------------------------------------- | +| `.` (webview client + React, mixed) | `.` (shared, no `vscode` / no `react`), `./webview` (client), `./react` (hooks) | +| `./server` (host) | `./host` | + +Import-side rules the split enforces: + +- `.` imports no `vscode` and no `react`. +- `./webview` imports no `react`. +- `./react` is the only entry that pulls in React. +- `./host` is the only entry that pulls in `vscode`. + +### Symbols + +| Old (`@microsoft/vscode-ext-react-webview...`) | New | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/server`: `publicProcedure`, `router` | `.`: `initWebviewTrpc()` returns `{ router, publicProcedure, createCallerFactory, middleware }`; `publicProcedure`, `router` also re-exported from `.` | +| `/server`: `BaseRouterContext` | `.`: `BaseRouterContext` | +| `/server`: `createMiddleware` | RETIRED. Use `telemetryMiddlewareBody` / `loggingMiddlewareBody` from `./host` via `publicProcedure.use(...)` | +| `/server`: `TelemetryContext` | RETIRED. Use `ProcedureTelemetry` + `TelemetryRunner` + `ProcedureLogger` from `./host` | +| `/server`: `WebviewController` (bespoke constructor) | `./host`: `WebviewController` (single options-bag constructor) and the `openWebview(extensionContext, options)` factory | +| `.`: `vscodeLink`, `errorLink`, `createEventChannel` | `./webview`: `vscodeLink`, `errorLink`, `createEventChannel` / `RpcEventChannel`; plus new `connectTrpc` | +| `.`: `useTrpcClient` (tuple return) | `./react`: `useTrpcClient()` returns the client directly; new `useRpcEvents()` returns the event channel | +| `.`: `UseTrpcClientOptions` | RETIRED | +| `.`: `useConfiguration`, `WebviewContext`, `WithWebviewContext` | `./react`: same names | + +New primitives that had no old equivalent: + +- `./host`: `attachTrpc(panel, context, router, callerFactory?, logger?)` returns + `{ disposable, activeOperations, activeSubscriptions }` (bring-your-own-panel). +- `./host`: `consoleProcedureLogger`, `ProcedureLogger`, `TelemetryRunner`, + `ProcedureTelemetry`, `ProcedureInvocation`, `MiddlewareResultLike`. +- `./webview`: `connectTrpc(vscodeApi, options?)` returns `{ client, events }`. +- `.`: `TypedEventSink`, `DiscriminatedEvent`, `EventOfType`, `StopOperation`. + +--- + +## 3. Telemetry model migration + +The old `createMiddleware` produced a telemetry middleware that wrapped +`callWithTelemetryAndErrorHandling` itself and, as a side effect of tRPC's +inference, statically widened `ctx.telemetry` to non-optional inside procedures. + +The new model inverts this: the package ships only the middleware body +(`telemetryMiddlewareBody`) and a runner contract (`TelemetryRunner`); the +consumer supplies the adapter that wraps `callWithTelemetryAndErrorHandling`, so +the event-name semantics stay entirely in consumer hands. + +DocumentDB's adapter lives in `src/webviews/_integration/trpc.ts`: + +```typescript +import { + telemetryMiddlewareBody, + type ProcedureTelemetry, + type TelemetryRunner, +} from '@microsoft/vscode-ext-webview/host'; +import { callWithTelemetryAndErrorHandling, parseError, type ITelemetryContext } from '@microsoft/vscode-azext-utils'; + +export type WithTelemetry<T extends { telemetry?: unknown }> = Omit<T, 'telemetry'> & { telemetry: ITelemetryContext }; + +const documentDbTelemetryRunner: TelemetryRunner = { + async run(invocation, execute) { + const result = await callWithTelemetryAndErrorHandling( + `${WEBVIEW_CONFIG.telemetry.rpcEventPrefix}.${invocation.type}.${invocation.path}`, + async (context) => { + context.errorHandling.suppressDisplay = true; + // ITelemetryContext is structurally wider than ProcedureTelemetry; the + // runtime value is the real ITelemetryContext, so this round-trips. + return execute(context.telemetry as unknown as ProcedureTelemetry); + }, + ); + if (!result) { + throw new Error('telemetry runner returned no result'); + } + return result; + }, +}; + +export const publicProcedureWithTelemetry = publicProcedure.use((opts) => + telemetryMiddlewareBody(opts, documentDbTelemetryRunner), +); +``` + +Two consequences worth calling out: + +1. `publicProcedureWithTelemetry` is now a CONSUMER export (in `trpc.ts`), not a + package export. It is built once and re-exported through `appRouter.ts`. +2. `telemetryMiddlewareBody` returns plain `TResult`, so tRPC does NOT widen + `ctx.telemetry` to non-optional the way the retired `createMiddleware` did. + Procedures that read telemetry must narrow the context themselves: + +```typescript +getData: publicProcedureWithTelemetry.input(z.object({ id: z.string() })).query(async ({ ctx }) => { + const myCtx = ctx as WithTelemetry<RouterContext>; + myCtx.telemetry.properties.somethingUseful = 'value'; + // ... +}); +``` + +This `myCtx` narrowing is the single most common edit when porting routers. + +--- + +## 4. Hook split (before / after) + +The old hook returned a tuple-like object and bundled an optional error observer. +The new split is two single-purpose hooks. + +Before: + +```tsx +import { useTrpcClient } from '../_integration/useTrpcClient'; +import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; + +const { trpcClient } = useTrpcClient(); +const config = useConfiguration<MyConfig>(); +``` + +After: + +```tsx +import { useTrpcClient } from '../_integration/useTrpcClient'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; + +const trpcClient = useTrpcClient(); // client directly, no destructuring +const config = useConfiguration<MyConfig>(); + +// Only if you need the success / error / aborted stream: +import { useRpcEvents } from '@microsoft/vscode-ext-webview/react'; +const events = useRpcEvents(); +``` + +DocumentDB kept a thin `src/webviews/_integration/useTrpcClient.ts` wrapper that +pins the generic to `AppRouter`: + +```typescript +import { useTrpcClient as useFrameworkTrpcClient } from '@microsoft/vscode-ext-webview/react'; +import { type AppRouter } from './appRouter'; + +export function useTrpcClient() { + return useFrameworkTrpcClient<AppRouter>(); +} +``` + +--- + +## 5. Two migration paths for a panel-owning consumer + +Both paths assume the consumer has a tRPC root router (`appRouter`), a +`createCallerFactory`, a `BaseRouterContext`-derived context type, and a bundle / +dev-server layout (`sourceLayout`, `devServerHost`). + +### Path A (class): extend the new `WebviewController` + +Lowest churn. Keeps stateful or method-rich controllers as classes. This is the +options-bag constructor; everything the framework needs is one object. + +Before (old package): + +```typescript +import { WebviewController } from '@microsoft/vscode-ext-react-webview/server'; + +export class MyViewController extends WebviewController { + constructor(initialData: MyConfig) { + super(ext.context, title, 'myView', initialData); + this.setupTrpc({ dbExperience: API.DocumentDB, webviewName: 'myView' /* ... */ }); + } +} +``` + +After (new package, options-bag): + +```typescript +import { WebviewController } from '@microsoft/vscode-ext-webview/host'; +import { appRouter, type AppRouter, type BaseRouterContext } from '../../_integration/appRouter'; +import { createCallerFactory } from '../../_integration/trpc'; +import { WEBVIEW_CONFIG } from '../../_integration/configuration'; + +export class MyViewController extends WebviewController<AppRouter, MyConfig, BaseRouterContext> { + constructor(initialData: MyConfig) { + const context: BaseRouterContext = { dbExperience: API.DocumentDB, webviewName: 'myView' /* ... */ }; + super({ + extensionContext: ext.context, + title, + viewType: 'myView', + router: appRouter, + createCallerFactory, + context, + config: initialData, + sourceLayout: WEBVIEW_CONFIG.bundle, + devServerHost: WEBVIEW_CONFIG.devServerHost, + icon, + }); + } +} +``` + +DocumentDB used a shared `WebviewControllerBase` to capture this options-bag +`super(...)` call once (WI-E1), then removed it when every panel moved to Path B. + +### Path B (factory): `openWebview` via a consumer preset + +Best for construction-only panels (a single constructor, no instance state, and +call sites that only touch the returned handle). First wrap `openWebview` in a +small consumer preset so each panel factory stays short: + +```typescript +// src/webviews/_integration/openAppWebview.ts +import { openWebview, type WebviewController } from '@microsoft/vscode-ext-webview/host'; + +export type AppWebviewController<TConfiguration> = WebviewController<AppRouter, TConfiguration, BaseRouterContext>; + +export function openAppWebview<TConfiguration>(options: { + title: string; + webviewName: WebviewName; + config: TConfiguration; + context: BaseRouterContext; + viewColumn?: vscode.ViewColumn; + icon?: vscode.Uri | { light: vscode.Uri; dark: vscode.Uri }; +}): AppWebviewController<TConfiguration> { + return openWebview<AppRouter, TConfiguration, BaseRouterContext>(ext.context, { + title: options.title, + viewType: options.webviewName, + router: appRouter, + createCallerFactory, + context: options.context, + config: options.config, + sourceLayout: WEBVIEW_CONFIG.bundle, + devServerHost: WEBVIEW_CONFIG.devServerHost, + icon: options.icon, + viewColumn: options.viewColumn, + }); +} +``` + +Then each panel becomes a factory function: + +```typescript +export function openMyViewPanel(initialData: MyConfig): AppWebviewController<MyConfig> { + const context: BaseRouterContext = { dbExperience: API.DocumentDB, webviewName: 'myView' /* ... */ }; + return openAppWebview({ title, webviewName: 'myView', config: initialData, context }); +} +``` + +Call-site change (the returned handle preserves the lifecycle methods): + +```typescript +// before +const view = new MyViewController(initialData); +view.onDisposed(() => cleanup()); +view.revealToForeground(); + +// after +const view = openMyViewPanel(initialData); +view.onDisposed(() => cleanup()); +view.revealToForeground(); +``` + +The handle exposes `panel`, `onDisposed`, `revealToForeground`, `isDisposed`, +and `dispose`. If the router context needs the panel before the handle exists +(for example a title setter), assign through a small holder so the closure reads +it at call time: + +```typescript +const handle: { controller?: AppWebviewController<MyConfig> } = {}; +const context: BaseRouterContext = { + /* ... */ + viewPanelTitleSetter: (title) => { + if (handle.controller) { + handle.controller.panel.title = title; + } + }, +}; +handle.controller = openAppWebview({ title, webviewName: 'myView', config: initialData, context }); +return handle.controller; +``` + +### Choosing and sequencing A vs B + +- Construction-only panel (no instance state, no externally-called methods beyond + the handle): go to Path B. +- Stateful or method-rich controller (other code calls its methods, or it holds + instance fields): stay on Path A. This is a supported end state, not a + shortcoming. +- A and B can be SEQUENCED: land Path A first for a safe, minimal diff that just + points at the new package, then convert construction-only controllers to Path B + in a follow-up (this is what the DocumentDB migration did across WI-E1 and + WI-E4). Or do both in one step if the panel is obviously construction-only. + +--- + +## 6. Embedders: bring-your-own-panel with `attachTrpc` + +Consumers that already own a `vscode.WebviewPanel` (for example vscode-cosmosdb, +which forked the transport before this package existed) do not need +`WebviewController` or `openWebview` at all. Wire tRPC onto the existing panel +with `attachTrpc` from `./host`: + +```typescript +import { attachTrpc } from '@microsoft/vscode-ext-webview/host'; + +const { disposable, activeOperations, activeSubscriptions } = attachTrpc( + panel, // an existing vscode.WebviewPanel you own + context, // your BaseRouterContext-derived context + appRouter, // your tRPC root router + createCallerFactory, // optional; defaults to the package's caller factory + consoleProcedureLogger, // optional dispatch-level logger +); + +context.subscriptions.push(disposable); +``` + +`attachTrpc` returns the disposable to register with the panel lifecycle plus the +live maps of in-flight operations and subscriptions (useful for diagnostics or +custom cancellation). The webview side connects with `connectTrpc(vscodeApi)` +from `./webview`, which returns `{ client, events }` with no React dependency. + +When navigating the package itself, each folder under +`packages/vscode-ext-webview/src/` (`shared`, `host`, `host/middleware`, +`webview`, `react`) carries a short README describing its side, import path, and +contents. Start there for an at-a-glance map. + +--- + +## 7. Quick checklist for porting one consumer + +1. Swap the dependency name in `package.json` and update imports to the four new + subpaths (`.`, `./host`, `./webview`, `./react`). +2. Replace `createMiddleware` with a `TelemetryRunner` adapter and a + `publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner))` export. +3. Narrow telemetry reads in procedures with a `WithTelemetry<...>` cast. +4. Change `const { trpcClient } = useTrpcClient()` to `const trpcClient = useTrpcClient()`. +5. Pick Path A or Path B per panel (construction-only goes to B). +6. Run the full gate: lint, tests, build, and a manual webview smoke (open each + view and round-trip one tRPC call). diff --git a/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-review.md b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-review.md new file mode 100644 index 000000000..fd8f19393 --- /dev/null +++ b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-ext-review.md @@ -0,0 +1,2184 @@ +# PR 766 Review: webview API redesign + +Date: 2026-07-02 +Branch: `dev/tnaum/webview-api-refinements` +PR: https://github.com/microsoft/vscode-documentdb/pull/766 + +## Scope + +Reviewed the redesigned `@microsoft/vscode-ext-webview` package, its migration docs, and the local DocumentDB integration with two goals: + +- Find code/API correctness issues, classified by severity. +- Second-guess the exposed API for simplification and onboarding, especially for new projects and coding agents. +- Merge in Copilot reviewer comments from GitHub, preserving review-thread references for follow-up. + +The original review and second pass (below) made no code changes. **Iteration 1 +(2026-07-03) then implemented most findings** as individual commits on the PR +branch — see the [change protocol](#iteration-1--change-protocol-2026-07-03). +Deferred items and answers to open questions are in +[Iteration 2](#iteration-2--open-items--answers). **Iteration 3 (2026-07-05)** +implemented the one item Iteration 2 held back (R766-N05, event-observer +isolation). **Iteration 4 (2026-07-05)** triages the GitHub Copilot reviewer's +second automated pass and analyzes each comment. **Iteration 5 (2026-07-05)** +steps back from correctness and looks at package consumer ergonomics and +onboarding friction. **Iteration 6 (2026-07-05)** fixes a runtime regression +(R766-07): the webview failed to load in the standard bundled-development flow +because the source layout was keyed off the extension mode instead of the bundle +flag. **Iteration 7 (2026-07-05)** tidies the reference `_integration` folder: +groups the observability sinks into a subfolder and brings the folder README +back in sync. **Iteration 8 (2026-07-06)** is an analysis pass (no code): it +traces the webview open/load and message-processing paths for latency and +load-time degradation and finds the load path neutral-to-better, with one +recurring per-RPC host-side cost (the dispatch logger) flagged as a follow-up. +**Iteration 9 (2026-07-06)** implements R766-P05 part 1: gates the per-op host +`console.log` behind `extensionMode !== Production` so shipped builds never pay +for it. **Iteration 10 (2026-07-06)** implements R766-P05 part 2: the +`callWithAccumulatingTelemetry` accumulate/flush redesign (Option 1) — the +per-call path is now cheap in-memory work and the telemetry pipeline is entered +only on flush, with all callers migrated to the new sample-bag callback. +**Iteration 11 (2026-07-06)** renames that helper to `accumulateTelemetry` +(+ `flushAccumulatedTelemetry`, file → `accumulatingTelemetry.ts`) so the name no +longer implies the full-context scoped call of the `callWith…` convention, and +files the "wrap an action" variant (`runWithAccumulatingTelemetry`) as a tracked +enhancement issue rather than building it now. + +## Summary + +The main architectural decision looks right: splitting the old bundled package into `.` shared, `./host`, `./webview`, and `./react` gives the package a usable primitive layer without taking away the greenfield `openWebview` path. The public verb system (`init`, `open`, `attach`, `connect`, `use`) is coherent and should be easier for humans and agents to discover than the previous package. + +The main concern is that two implementation details still assume the package is mostly self-owned: `attachTrpc` does not tolerate unrelated messages on an existing panel, and `WebviewController.dispose()` does not close the panel even though `dispose` is advertised on the public handle. Those are fixable without revisiting the broader design. + +Copilot reviewer left three unresolved GitHub review threads. I agree with the two `AsyncIterator.return()` comments as a low-severity cleanup, and partially agree with the `openDocumentWebview` comment as a low-severity API-shape issue rather than a proven strict-mode failure. + +## Iteration 1 — change protocol (2026-07-03) + +> This section is the **protocol of changes** for iteration 1: what shipped, why, +> and the commit that carries it. Each fix is an individual commit on the PR +> branch, pushed to `dev/tnaum/webview-api-refinements`, and acknowledged on the +> PR — a general comment, or a reply on the originating Copilot review thread +> (which was also resolved). Items deferred to a second pass are marked +> **⏭ Moved to Iteration 2** in their sections below and consolidated in +> [Iteration 2 — open items & answers](#iteration-2--open-items--answers). + +**Post-change validation (all green):** `npm run l10n` (no drift) · `prettier` (no +drift) · `eslint --quiet` (clean) · `jest` (2648 passed / 157 suites) · `tsc` +build across all workspaces (clean). + +| ID | Commit | What changed | Why (motivation) | +| -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| R766-06 | `0292780` | `attachTrpc` calls `iterator.return?.()` with **no argument** at both sites | The parameter is a _return value_, not an `IteratorResult`; `{ value, done }` was misleading and could leak as a custom iterator's final value. Copilot threads answered + resolved. | +| R766-N04 | `0bd16afa` | `AttachTrpcResult` exposes `activeOperations` / `activeSubscriptions` as `ReadonlyMap` | Returning the live mutable `Map`s let a consumer corrupt the dispatcher's in-flight/cancellation bookkeeping; observation preserved, mutation removed. | +| R766-05 | `c5662718` | `openDocumentWebview` returns a local `const controller` | The return no longer flows through the optional `handle.controller?` slot, so it can't read as nullable. Copilot thread answered + resolved. | +| R766-02 | `76172cd2` | `WebviewController.dispose()` now closes the panel (`_panelDisposed` guard) + 2 tests | A public handle whose `dispose()` leaves the tab open is surprising; the old "recursion" rationale was already neutralised by the `_isDisposed` guard. | +| R766-N07 | `9a758cc5` | `useConfiguration` parses `__initialData` in `try/catch`, falls back to `{}` + logs | A malformed payload threw during render and white-screened the webview; degrade gracefully instead. | +| R766-N08 | `76f99484` | Renamed the host dispatch-logger option `telemetry` → `logger`; added README **Observability** chapter | `telemetry` (a `ProcedureLogger`) collided with the analytics path and misled readers. No deprecated alias kept (preview). | +| R766-S02 | `6363a6f2` | Webview `loggerLink` is now **opt-in** (`connectTrpc({ logger })` / `<WithWebviewContext enableRpcLogging>`) | Always-on logging is noise for production consumers; defaults should be quiet. README documents the rich console experience and how to open the webview devtools console. | +| R766-S03 | `32859afc` | Shipped generic `WithTelemetry<TContext, TTelemetry>` from `./host`; ADVANCED.md pattern; DocumentDB now specializes it; README telemetry recipe | Reading `ctx.telemetry` needed ad-hoc casts, and the DocumentDB comment referenced a package helper that didn't exist. Telemetry is now discoverable from the README (azext). | +| R766-S04 | `de27b507` | Reworded README shared-client note; dropped the "single `message` listener" claim | The client is shared per webview (true), but the transport registers one listener _per in-flight op_; the claim described a wrong, changeable internal. (Design pros/cons in Iteration 2.) | +| R766-04 | `61a01033` | ADVANCED.md: subscriptions are **not** on the event channel | The doc contradicted `eventLink` (which excludes subscriptions) and the `useRpcEvents` doc. | +| R766-03 | `46296ce4` | Ship `ADVANCED.md` + a package-local `LICENSE` in `files` | README linked ADVANCED.md ~10× but it wasn't in the tarball, and no license text shipped. Verified with `npm pack --dry-run`. | + +**Deferred to Iteration 2 (no code this pass):** R766-01 (foreign-message guard), +R766-N01 (webview inbound guard — depends on R766-01), R766-N02 (caller-factory +ergonomics), R766-N03 (inline-script hardening, option B), R766-N05 (observer +exceptions → telemetry), R766-N06 (create-or-reveal). R766-S01 stands as agreed +(keep the three tiers; no change). + +## Findings + +### R766-01: High - `attachTrpc` cannot safely attach to panels with existing `postMessage` traffic + +> ⏭ **Moved to Iteration 2** (skipped this pass, as requested). It affects only +> bring-your-own-panel embedders of `attachTrpc`; the panel-owning `openWebview` / +> `WebviewController` path used by DocumentDB never carries foreign traffic, so +> current consumers pay nothing today. The Iteration 2 chapter re-analyses options +> A/B/C and the side effects on existing projects. + +Reference: [packages/vscode-ext-webview/src/host/attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L331-L348) + +`attachTrpc` registers a webview message listener and immediately reads `message.op.type`. Any panel that already uses `postMessage` for a non-tRPC message will send an object without `op`, which causes the handler to throw before the message can be ignored. + +This cuts against the central reason `attachTrpc` exists: bring-your-own-panel adopters, including migrations from legacy `postMessage` channels, are exactly the consumers most likely to have unrelated traffic on the same VS Code webview bus. The design notes explicitly mention that the Cosmos-derived primitive guarded non-tRPC messages for this reason. + +Suggested fix: add a small runtime guard before the `switch`, for example checking that the message is an object with an `op` object whose `type` is one of the supported transport operation types. Unknown messages should be ignored, optionally with debug logging only when a logger/debug option is supplied. Add a unit test that sends an unrelated message and then verifies a later tRPC query still works. + +### R766-02: Medium - Public `WebviewController.dispose()` leaves the webview panel open + +Reference: [packages/vscode-ext-webview/src/host/WebviewController.ts](../../../../packages/vscode-ext-webview/src/host/WebviewController.ts#L311-L338) + +The README advertises the `openWebview` return value as a handle with `dispose`, but `dispose()` only marks the controller disposed, fires `onDisposed`, and disposes registered listeners. It deliberately does not call `this._panel.dispose()`, based on the internal assumption that only the panel owns the controller lifecycle. + +As a public package API, that assumption is too narrow. A consumer holding the returned controller can reasonably call `controller.dispose()` expecting the tab to close. Instead, the visible webview remains open while its controller is marked disposed and its tRPC listener has been torn down. That is a confusing state for new projects and agents because the obvious cleanup method only half-disposes the object graph. + +Suggested fix: split disposal into two paths. Public `dispose()` should close the panel when the panel is not already disposing, while the `onDidDispose` callback should call a private teardown method that only disposes controller resources. Keep the idempotency guard to avoid recursion. Add an `openWebview` test asserting that `controller.dispose()` calls the mock panel's `dispose()`. + +### R766-03: Medium - `ADVANCED.md` is referenced but excluded from the npm package + +References: [packages/vscode-ext-webview/package.json](../../../../packages/vscode-ext-webview/package.json#L38-L41), [packages/vscode-ext-webview/README.md](../../../../packages/vscode-ext-webview/README.md#L216-L238) + +The package whitelist ships only `dist` and `README.md`, but the README repeatedly sends advanced users to `ADVANCED.md`. Consumers installing the package from npm will not get that file in the tarball. + +This matters because the opened-up API intentionally relies on advanced docs for `attachTrpc`, `connectTrpc`, telemetry adapters, event channels, and the host/browser import boundary. Without the file in the package, the lower-level surface is harder to adopt and the README contains broken local links. + +Suggested fix: include `ADVANCED.md` in the package `files` array. Consider also including license metadata if the eventual published tarball does not already include it through npm defaults. + +### R766-04: Low - `ADVANCED.md` contradicts the implementation for subscription errors + +References: [packages/vscode-ext-webview/ADVANCED.md](../../../../packages/vscode-ext-webview/ADVANCED.md#L195-L199), [packages/vscode-ext-webview/src/webview/errorLink.ts](../../../../packages/vscode-ext-webview/src/webview/errorLink.ts#L55-L88), [packages/vscode-ext-webview/src/react/useRpcEvents.ts](../../../../packages/vscode-ext-webview/src/react/useRpcEvents.ts#L16-L23) + +The advanced manual says subscription errors are surfaced through the global event channel as well as each subscription's `.subscribe({ onError })` handler. The implementation intentionally excludes subscriptions from `eventLink`, and the hook docs say subscriptions are excluded to avoid surfacing them twice. + +Suggested fix: update `ADVANCED.md` to match the implementation: query and mutation outcomes go through `RpcEventChannel`; subscription outcomes should be observed through the subscription callback. + +### R766-05: Low - `openDocumentWebview` uses an optional handle for a value it returns as required + +GitHub review thread: `PRRT_kwDOODtcO86OCV7g` (Copilot reviewer, unresolved, can resolve) + +Reference: [src/webviews/documentdb/documentView/documentsViewController.ts](../../../../src/webviews/documentdb/documentView/documentsViewController.ts#L41-L73) + +Copilot flagged that `openDocumentWebview` declares a non-optional return type but stores the controller in `handle.controller?: AppWebviewController<...>` and then returns `handle.controller`. + +I would classify this as low severity. The assignment happens immediately before the return, so this is unlikely to be a runtime bug, and TypeScript may narrow the property after direct assignment. Still, the optional property makes the code harder for humans and agents to reason about, and it creates an unnecessary question about whether `undefined` can escape. + +Suggested fix: avoid returning through the optional property. Store the result in a local `const controller = openAppWebview(...)`, assign `handle.controller = controller` for the title setter closure, and return `controller`. That keeps the deferred setter pattern without making the function's return path look nullable. + +### R766-06: Low - `AsyncIterator.return()` is called with an `IteratorResult`-shaped value + +GitHub review threads: `PRRT_kwDOODtcO86OCV77` and `PRRT_kwDOODtcO86OCV8H` (Copilot reviewer, both unresolved, both can resolve) + +References: [packages/vscode-ext-webview/src/host/attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L250-L260), [packages/vscode-ext-webview/src/host/attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L366-L375) + +Copilot flagged both calls to `iterator.return?.({ value: undefined, done: true })`. The comment is valid: `AsyncIterator.return(value?)` accepts the return value, not an `IteratorResult` object. Passing `{ value, done }` is misleading and can leak that object as the iterator's final return value for custom iterators. + +I would classify this as low severity because the current code uses the call mainly to unblock a parked subscription and does not consume the return value. It is still worth fixing because this package is a reusable transport primitive and should model iterator semantics accurately. + +Suggested fix: call `iterator.return?.()` with no argument in both locations. If a domain return value is ever needed, pass that domain value directly, not an `IteratorResult` shape. Keep the existing rejection swallowing and subscription cleanup behavior. + +## API simplification notes + +### R766-S01: Keep `openWebview`, `WebviewController`, and `attachTrpc` as separate tiers + +I would not collapse the three host tiers. The factory is useful for greenfield consumers, the class is useful for stateful panels, and `attachTrpc` is the adoption primitive for existing panel frameworks. The important part is that `attachTrpc` must behave like a true guest on a panel it does not own, which is why R766-01 is the key fix. + +### R766-S02: Consider making production logging opt-in or mode-aware + +Reference: [packages/vscode-ext-webview/src/webview/connectTrpc.ts](../../../../packages/vscode-ext-webview/src/webview/connectTrpc.ts#L105-L107) + +`connectTrpc` always inserts tRPC's `loggerLink()`. That is friendly while developing a starter project, but it may surprise production consumers by logging every RPC call from the webview side. Since `WebviewController` already has explicit telemetry/logging options on the host side, the client side would be more predictable if logging were controlled by a `logger` / `loggerLink` / `enableLogger` option, or omitted by default in production-oriented helpers. + +This is not a correctness bug, but it is worth second-guessing before the preview API spreads. New projects and coding agents usually accept defaults; defaults should be quiet unless observability is explicitly requested. + +### R766-S03: The telemetry middleware is flexible, but the common `WithTelemetry<T>` cast deserves a first-class docs pattern + +The decision to ship middleware bodies instead of a package-owned telemetry procedure is sound. It keeps the package instance-agnostic and avoids baking in Azure telemetry policy. + +The cost is that consumers need a local `WithTelemetry<T>` helper or a similar narrowing pattern when procedure code reads `ctx.telemetry`. DocumentDB already has that helper in [src/webviews/\_integration/trpc.ts](../../../../src/webviews/_integration/trpc.ts#L58-L64). Add that pattern to `ADVANCED.md` so agents have a copyable way to do the right thing instead of inventing ad hoc casts at every procedure. + +### R766-S04: The README should avoid promising a single message listener + +Reference: [packages/vscode-ext-webview/README.md](../../../../packages/vscode-ext-webview/README.md#L202-L208) + +The README says every component receives the same client and "the same single `message` listener." The shared-client part is true, but the transport currently registers per-operation `message` listeners under `vscodeLink`. The claim is not important to the API contract and can be softened to avoid describing internals that may change. + +## Bottom line + +I would keep the redesigned package shape. It is meaningfully simpler than the old bundled API for greenfield users, while still exposing the lower layer needed by existing webview frameworks. + +Before merging, I would address R766-01 and R766-02 as the blocking API-behavior issues, and R766-03 as a package publishing issue. R766-04 and the simplification notes can be handled as follow-ups if necessary, but they are cheap enough to fix before the preview goes out. + +--- + +# Independent second review (Reviewer 2) + +Added 2026-07-03. This is a fresh pass over the same PR with three goals: (1) +re-check every finding above against the actual code and re-assess its severity +independently (the first pass could contain false alarms), (2) add findings the +first pass missed (`R766-N*`), and (3) give every discovery an +options/pros-cons/recommendation block so a follow-up discussion can be focused +by ID. + +I also read the locked design (`docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-rpc-package-decoupling-design.md`, +§13 "Decisions summary"). **Bottom line up front: the implementation faithfully +matches the locked design** — the four subpaths, the `open`/`attach`/`connect` +verb system, `initWebviewTrpc`, middleware bodies + adapters, and the +`useTrpcClient`/`useRpcEvents` split are all present and shaped as decided. Every +finding below is hardening or polish, **not** an architectural miss. The two +findings that actually cut against the stated north star ("simple for the 90%, +pluggable for embedders") are R766-N02 (the simple path is taxed by an extra, +mismatch-prone `createCallerFactory` wire) and R766-01 (the embedder path is +under-hardened against foreign panel traffic). Those two are where I would spend +the effort. + +## Verification of the existing findings + +| ID | 1st-pass severity | Verified in code? | My severity | Verdict | +| -------- | ----------------- | ----------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| R766-01 | High | ✅ Yes | **Medium** | Real defect, but I downgrade High → Medium. See reasoning below. Still the highest-priority functional fix. | +| R766-02 | Medium | ✅ Yes | Medium | Confirmed. The doc-comment's "circular call chain" justification is itself partly wrong (the `_isDisposed` guard already prevents recursion). | +| R766-03 | Medium | ✅ Yes | Medium | Confirmed. Broaden it: no `LICENSE` file ships either. | +| R766-04 | Low | ✅ Yes | Low | Confirmed doc/impl contradiction. | +| R766-05 | Low | ✅ Yes | Low | Confirmed. Compiles only because TS narrows the property after direct assignment; not a runtime bug. | +| R766-06 | Low | ✅ Yes | Low | Confirmed at both sites. | +| R766-S01 | note | n/a | Info | Agree — three tiers are implemented as designed. | +| R766-S02 | note | ✅ Yes | Low | Confirmed `loggerLink()` is unconditional. | +| R766-S03 | note | ✅ Yes | Low | Confirmed; and the DocumentDB comment references a package `WithTelemetry` helper that does **not** exist. | +| R766-S04 | note | ✅ Yes | Low | Confirmed — the transport registers a `window` `message` listener per in-flight operation. | + +No finding in the first pass was a false alarm. The only correction is the +severity of R766-01 and two rationale/scope refinements (R766-02, R766-03). + +### R766-01 severity: why Medium, not High + +> ⏭ **Moved to Iteration 2.** Options A/B/C are re-examined in the Iteration 2 +> chapter, together with the side-effect analysis for existing projects. + +Reference: [packages/vscode-ext-webview/src/host/attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L331-L333) + +The defect is real and verified: the listener callback is `async` and its first +act is `switch (message.op.type)`, with no guard. A foreign `postMessage` whose +payload has no `op` (or is `null`) throws a `TypeError`. + +What tempers the severity is the _blast radius_: + +- The throw happens **inside an `async` listener**, so it becomes an _unhandled + promise rejection_, not a synchronous throw. VS Code's event emitter does not + catch it (nothing is thrown synchronously), and the extension host does not + abort on unhandled rejections by default — it logs. So the observable effect + is log/telemetry noise plus a dropped foreign message, **not** a crash. +- It does **not** corrupt the tRPC channel: each message is a fresh listener + invocation, so subsequent tRPC calls still dispatch correctly. +- It does **not** break the embedder's _own_ `onDidReceiveMessage` listener — + VS Code fans a message out to every registered listener independently, so the + consumer's own protocol handler still receives the message. + +So the true impact is "the BYO-panel primitive is noisy and leaks unhandled +rejections whenever the panel also carries non-tRPC traffic" — which is exactly +the scenario `attachTrpc` was extracted to serve (design §3.1 notes the +Cosmos-derived primitive kept a defensive guard for precisely this). That makes +it the **top-priority functional fix**, but Medium is the honest severity: it +degrades a first-class use case rather than corrupting data or crashing. + +**Options** + +- **A — Guard before the switch.** Ignore any message that is not an object + carrying an `op` object whose `type` is a known transport op; optionally emit + one debug line through the already-present `ProcedureLogger`. + - Pros: ~5 lines; matches the proven Cosmos guard; restores the primitive's + stated purpose; trivially unit-testable (send junk, then assert a later + query still resolves). + - Cons: silent-ignore can mask a genuinely malformed tRPC message unless you + add the debug log. +- **B — Also make the top-level listener throw-safe.** Keep the guard, and wrap + the dispatch so a handler throw can never escape as an unhandled rejection + (defence in depth for the whole class of bug). + - Pros: eliminates the unhandled-rejection category, not just this instance. + - Cons: slightly larger; still needs A for the actual routing decision. +- **C — Namespace the wire protocol.** Wrap every framework message in a + discriminator (e.g. `{ __vscodeExtWebview: 1, … }`) so foreign traffic is + unambiguous. + - Pros: robust and future-proof if the panel ever multiplexes channels. + - Cons: **breaking** wire change touching `vscodeLink` + host; violates the + "wire protocol deliberately unchanged" line in the design's anti-churn + ledger (§12.7). Overkill for preview. + +**Recommendation: A now, plus B as cheap hardening.** Log ignored messages +through the optional logger so nothing is masked. Reject C for the preview — +revisit only if real channel-multiplexing demand appears. + +### R766-02 — refinement + +Reference: [packages/vscode-ext-webview/src/host/WebviewController.ts](../../../../packages/vscode-ext-webview/src/host/WebviewController.ts#L323-L338) + +Confirmed. One correction to the code's own reasoning: the doc comment says the +panel is not closed to avoid a "circular call chain +(`dispose → panel.dispose → onDidDispose → dispose`)", but `dispose()` sets +`_isDisposed = true` _before_ doing anything else, so a re-entrant call already +returns immediately. The recursion the comment fears cannot happen — which means +the stated reason for the current behavior does not hold, and closing the panel +is safe. + +Corroborating evidence that this is a latent (not yet triggered) issue: a repo +search shows DocumentDB never calls `controller.dispose()` or +`revealToForeground()` on these controllers, and the `openWebview` test only +asserts `isDisposed`/`onDisposed` — never that the panel closed +([openWebview.test.ts](../../../../packages/vscode-ext-webview/src/host/openWebview.test.ts#L84-L92)). +The handle method is essentially untested and unused in-repo, which is why the +gap survived. + +**Options** + +- **A — `dispose()` closes the panel.** Add `this._panel.dispose()` at the end + of `dispose()`, guarded by a private `_panelClosing` flag so the + `onDidDispose → dispose()` path does not attempt a second close. + - Pros: makes the handle behave the way every consumer (and the README) will + assume; smallest change; recursion already prevented. + - Cons: must confirm double `panel.dispose()` is a no-op (VS Code disposables + are idempotent; the flag makes it explicit). +- **B — Two verbs: `close()` (disposes the panel) vs `dispose()` (resources + only).** + - Pros: explicit about intent. + - Cons: two things to learn; fights the README, which already advertises + `dispose` as _the_ cleanup method; more surface for the "simple" audience. +- **C — Keep behavior, document it, add a `closePanel` option.** + - Pros: zero behavior change. + - Cons: least intuitive; directly contradicts the north star. + +**Recommendation: A.** Close the panel from `dispose()`, add the `_panelClosing` +guard, correct the doc comment, and extend the `openWebview` test to assert the +mock panel's `dispose()` was called. + +### R766-03 — broaden to include the license + +References: [package.json](../../../../packages/vscode-ext-webview/package.json#L38-L41), [README.md](../../../../packages/vscode-ext-webview/README.md#L334-L336) + +Confirmed: `files` is `["dist", "README.md"]`, so `ADVANCED.md` (linked ~10× +from the README) is not in the tarball. Additionally, the README's License +section links to `../../LICENSE.md` (repo root) and `package.json` sets +`"license": "MIT"`, but there is **no `LICENSE` file in the package directory**, +so npm ships no license text with the package — a compliance gap for a public +MIT package. + +**Options** + +- **A — Add `ADVANCED.md` and a package-local `LICENSE` to `files`.** + - Pros: trivial; fixes the dead links, ships the advanced manual, and closes + the license gap in one edit. + - Cons: none. (A short `LICENSE` that references the repo root is fine, or copy + the MIT text.) +- **B — Fold ADVANCED.md into README.** + - Pros: one document. + - Cons: bloats the README; worse for humans and agents scanning the quick + start; still needs the license fix. + +**Recommendation: A.** + +### R766-04 — align the doc down to the implementation + +References: [ADVANCED.md](../../../../packages/vscode-ext-webview/ADVANCED.md#L197-L199), [errorLink.ts](../../../../packages/vscode-ext-webview/src/webview/errorLink.ts#L71-L82), [useRpcEvents.ts](../../../../packages/vscode-ext-webview/src/react/useRpcEvents.ts#L20-L21) + +Confirmed contradiction. `eventLink` guards `if (op.type !== 'subscription')` on +both `next` and `error`, and the `useRpcEvents` doc comment says subscriptions +are intentionally excluded — but ADVANCED.md L197-199 says subscription errors +_are_ surfaced on `onError`. + +**Options** + +- **A — Fix the doc** to state subscription outcomes are observed via + `.subscribe({ onError })`, not the channel. + - Pros: matches deliberate, code-documented behavior; one-paragraph edit. + - Cons: none. +- **B — Change the impl** to publish subscription outcomes to the channel. + - Pros: makes the doc true as written. + - Cons: re-introduces the double-surfacing the code comment explicitly avoids; + worse ergonomics. + +**Recommendation: A.** + +### R766-05 — store in a local, return the local + +Reference: [documentsViewController.ts](../../../../src/webviews/documentdb/documentView/documentsViewController.ts#L41-L73) + +Confirmed low. It type-checks because TS narrows `handle.controller` to +non-`undefined` right after the direct assignment (no intervening call), so no +runtime bug. It is purely a readability/robustness nit. + +**Options** + +- **A — `const controller = openAppWebview(...); handle.controller = controller; return controller;`.** + - Pros: return path is obviously non-nullable; keeps the deferred title-setter + closure working. + - Cons: none. +- **B — Remove the deferred setter entirely** (e.g. set the title after open, or + pass a getter). + - Pros: drops the `handle` indirection. + - Cons: larger change for no functional gain. + +**Recommendation: A.** + +### R766-06 — call `return()` with no argument + +References: [attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L259), [attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L374) + +Confirmed at both sites. `AsyncIterator.return(value?)` takes the _return value_, +not an `IteratorResult`; passing `{ value: undefined, done: true }` sets that +object as the generator's final return value. Harmless today (nobody reads it), +but wrong modelling for a reusable transport. + +**Recommendation:** call `iterator.return?.()` with no argument at both sites; +keep the existing rejection-swallowing. (No competing option worth listing.) + +### R766-S02 / S03 / S04 — recommendations + +- **R766-S02** (`loggerLink()` always on — [connectTrpc.ts](../../../../packages/vscode-ext-webview/src/webview/connectTrpc.ts#L106)): + add `ConnectTrpcOptions.logger?: boolean` (default `false`); the facade and + hooks stay quiet unless observability is requested. Reject an env-based + default — webview bundles have no reliable `NODE_ENV`. **Recommend: opt-in + logger, off by default.** +- **R766-S03** (document the telemetry-narrowing pattern): add the + `WithTelemetry<T>` recipe to ADVANCED.md and optionally ship a generic + `type WithTelemetry<TCtx, TTelemetry>` from `./host`; also fix the stale + DocumentDB comment ([trpc.ts](../../../../src/webviews/_integration/trpc.ts#L60-L67)) + that calls itself a "replacement for the package's `WithTelemetry` helper" + when the package exports none. **Recommend: document + ship a tiny generic + helper.** +- **R766-S04** (README "single message listener" — [README.md](../../../../packages/vscode-ext-webview/README.md#L207-L211)): + soften to "a shared client per webview"; drop the "single `message` listener" + claim, which is false (one listener per in-flight op). **Recommend: reword.** + +## New findings (deeper review) + +### R766-N01: Medium - Webview-side inbound message guard throws on `null` / non-object `event.data` + +> ⏭ **Moved to Iteration 2**, bundled with R766-01 — both transport edges (host +> `attachTrpc` and webview `onReceive`) should be guarded together. + +Reference: [packages/vscode-ext-webview/src/webview/connectTrpc.ts](../../../../packages/vscode-ext-webview/src/webview/connectTrpc.ts#L92-L100) + +This is the webview-side mirror of R766-01. `onReceive` registers a `window` +`message` listener whose guard is `if ((event.data as VsCodeLinkResponseMessage).id)`. +A webview's `window` receives `message` events from many sources (the VS Code +host shell, dev-server/HMR clients, embedded iframes). When `event.data` is +`null` or `undefined`, `(event.data as …).id` throws a `TypeError` inside the +listener. Non-object primitives (a bare string) are falsy-safe, so the failure +is specifically the `null`/`undefined` case. + +Severity Medium because, unlike the host side, this throw is **synchronous** +inside a `window` event listener; it surfaces as an uncaught error in the webview +console on every such message and can fire repeatedly (e.g. an HMR client that +posts `null` pings). + +**Options** + +- **A — Structural guard:** `const data = event.data; if (data && typeof data === 'object' && 'id' in data && (data as …).id) { … }`. + - Pros: robust; ~2 lines; symmetric with the R766-01 host fix. + - Cons: none. +- **B — try/catch around the forward.** + - Pros: catches everything. + - Cons: hides real bugs; less precise than A. + +**Recommendation: A** (and ship it together with R766-01 so both transport +edges reject foreign traffic consistently). + +### R766-N02: Medium - The happy path is taxed by a separate, mismatch-prone `createCallerFactory` wire + +> ⏭ **Moved to Iteration 2.** The Iteration 2 chapter shows consumer code for +> options A and B side by side with today's usage. + +References: [initWebviewTrpc.ts](../../../../packages/vscode-ext-webview/src/shared/initWebviewTrpc.ts#L57-L64), [attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L134-L139), [ADVANCED.md](../../../../packages/vscode-ext-webview/ADVANCED.md#L88-L89) + +This is the finding most in tension with the "simple for the 90%" north star. To +get a typed context (the whole point of `initWebviewTrpc<Ctx>()`), the greenfield +consumer must: + +1. `const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<Ctx>()`; +2. **re-export** `createCallerFactory`; and +3. pass **both** `router` and `createCallerFactory` to `openWebview`. + +If step 2/3 is forgotten, `attachTrpc` silently falls back to +`defaultCreateCallerFactory` (bound to a _different_ tRPC instance). ADVANCED.md +warns this "works only when your router is built with the package's default +`router`/`publicProcedure`" — i.e. the mismatch is **silent** and only sometimes +correct. That is exactly the class of footgun a coding agent hits: three +coordinated steps, one of them easy to drop, failure mode non-obvious. + +**Options** + +- **A — Accept the whole `WebviewTrpc<Ctx>` (or a `{ router, createCallerFactory }` + pair) as one option.** One value that cannot be mismatched. + - Pros: eliminates the re-export and the mismatch; small, explicit. + - Cons: consumers pass an object instead of two fields. +- **B — Stamp the caller factory onto the router in `initWebviewTrpc`.** Have the + returned `router` builder tag routers with their originating factory; the + dispatcher reads `router.__callerFactory ?? default`. The `createCallerFactory` + option disappears from the happy path entirely. + - Pros: best ergonomics — the simple user passes only `router`, and a + mismatch is structurally impossible; matches "least code that can possibly + work." + - Cons: a touch of "magic" (a non-enumerable property on the router); needs a + typed accessor; still allow an explicit override for embedders. +- **C — Remove the silent default; require `createCallerFactory` (fail fast)** + whenever the router is not the `.`-exported default. + - Pros: no silent wrong-instance dispatch. + - Cons: hard to detect "is this the default router" reliably; risks throwing + on currently-working setups. +- **D — Dev-mode runtime check** comparing router/factory instance identity and + warning on mismatch. + - Pros: keeps the API; surfaces the footgun. + - Cons: instance identity is not cleanly exposed by tRPC; heuristic. + +**Recommendation: A** (decided in Iteration 2 — see the +[R766-N02 discussion](#r766-n02--consumer-code-today-vs-option-a-vs-option-b)). +B's zero-argument magic was rejected because its hidden router property is least +reliable for the tRPC-only, bring-your-own-router consumers the package also +serves; C's fail-fast can still be layered as a safety net for the non-default +case. This is the single highest-leverage simplification in the PR for humans and +agents: it removes an entire coordinated step from the documented quick start. + +### R766-N03: Low - Inline-script interpolation in the panel HTML is not escaped for script-context breakout + +> ⏭ **Moved to Iteration 2.** Option B (a non-executed `application/json` data +> block) and its consumer side effects are analysed in the Iteration 2 chapter. + +References: [WebviewController.ts](../../../../packages/vscode-ext-webview/src/host/WebviewController.ts#L267), [WebviewController.ts](../../../../packages/vscode-ext-webview/src/host/WebviewController.ts#L272-L276) + +`getDocumentTemplate` builds inline `<script>` blocks by string interpolation: + +- `globalThis.l10n_bundle = ${JSON.stringify(vscode.l10n.bundle ?? {})}` — plain + `JSON.stringify` does **not** escape `</script>`, `U+2028`, or `U+2029`, any of + which can break out of an inline script context. +- `render('${this._options.viewType}', …)` — `viewType` is interpolated raw + inside single quotes; a quote or `');…` in it breaks the statement. +- `__initialData` is the one field that _is_ protected (via `encodeURIComponent`). + +All inputs here are developer-controlled (the extension's own l10n bundle and its +own `viewType`), and a CSP nonce is applied, so real-world risk is low — hence +Low. But this is a _reusable, published_ package: a coding agent may feed a +dynamic `viewType`, and l10n bundles can contain arbitrary translated text. A +transport library should not have a latent HTML-injection edge. + +**Options** + +- **A — Escape inline JSON and encode `viewType`.** Add a `safeJsonForScript()` + that escapes `<`, `>`, `&`, `U+2028`, `U+2029`; pass `viewType` via + `JSON.stringify` instead of raw single quotes. + - Pros: cheap; removes the breakout edge; no API change. + - Cons: a small helper to maintain. +- **B — Deliver l10n + config via a non-executed `<script type="application/json">` + block** and parse it in the webview boot. + - Pros: content in a data block cannot break the script context at all; + cleanest. + - Cons: touches the webview boot contract (`window.config`, `l10n_bundle`). +- **C — Push initial data over `postMessage` after load** (Cosmos's convention). + - Pros: no inline data injection at all. + - Cons: adds a round-trip and a "loading" state to the _simple_ path; a + regression for the north-star audience. + +**Recommendation: A now** (removes the edge with no contract change); note **B** +as the cleaner long-term shape. Reject C for the facade. + +### R766-N04: Low - `AttachTrpcResult` leaks live mutable internal `Map`s + +Reference: [packages/vscode-ext-webview/src/host/attachTrpc.ts](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L64-L68) + +`attachTrpc` returns its internal `activeOperations` and `activeSubscriptions` +`Map`s directly (documented as "live map"). A consumer can `.clear()`, +`.delete()`, or overwrite entries and silently corrupt in-flight dispatch and +cancellation bookkeeping. The intent is observation, but the type invites +mutation. + +**Options** + +- **A — Type them as `ReadonlyMap<…>` in `AttachTrpcResult`** (internal code keeps + the mutable reference; cast at the boundary). + - Pros: preserves observability (size, iteration); removes the mutation + footgun; zero runtime cost. + - Cons: none material. +- **B — Expose only derived read-only accessors** (e.g. `activeOperationCount`). + - Pros: smallest surface. + - Cons: drops the ability to inspect ids; more API churn. +- **C — Leave as-is** (documented "live"). + - Pros: no change. + - Cons: keeps the footgun in a _primitive_ meant for embedders. + +**Recommendation: A.** + +### R766-N05: Low - A throwing event-channel observer breaks tRPC dispatch + +> ⏭ **Moved to Iteration 2.** Options for surfacing observer exceptions to +> telemetry (not just the console) are explored in the Iteration 2 chapter; not +> implemented this pass, per request. + +Reference: [packages/vscode-ext-webview/src/webview/events.ts](../../../../packages/vscode-ext-webview/src/webview/events.ts#L110-L129) + +`emitSuccess`/`emitError`/`emitAborted` invoke handlers synchronously inside the +`eventLink` `next`/`error` callbacks. The channel's own contract says it is +"observer-only" and cannot affect the value — but if an observer _throws_, the +exception propagates into the link chain and disrupts the very call it was only +supposed to observe. Snapshotting the handler set (already done) protects +iteration, not the caller. + +**Options** + +- **A — Isolate handler exceptions:** wrap each handler call in try/catch and + route to `console.error`. + - Pros: upholds the "cannot affect the value" contract; one small change. + - Cons: swallows observer bugs (acceptable — that is what observation means). +- **B — Document that handlers must not throw.** + - Pros: no code change. + - Cons: relies on every consumer/agent reading and obeying the note. + +**Recommendation: A.** + +### R766-N06: Info - No create-or-reveal / single-instance helper in the front door + +> ⏭ **Moved to Iteration 2** (unchanged recommendation: document the pattern, +> don't build it into the package yet). + +References: [openWebview.ts](../../../../packages/vscode-ext-webview/src/host/openWebview.ts#L42-L50), [documentsViewController.ts](../../../../src/webviews/documentdb/documentView/documentsViewController.ts#L29-L73) + +Every `openWebview` / `openAppWebview` call creates a **new** panel. DocumentDB +does not dedupe (opening the same document twice yields two tabs); Cosmos solves +this with `static openTabs` / `instances` registries in their `BaseTab`. This is +arguably consumer scope, but the north star is "best experience ever," and +create-or-reveal is the single most common panel pattern in real extensions. + +**Options** + +- **A — Ship an optional create-or-reveal helper** keyed by `viewType` + a + consumer-supplied key (returns the existing controller and reveals it if one is + open). + - Pros: removes boilerplate every consumer otherwise reinvents; great DX. + - Cons: adds panel-registry state to the package; lifecycle edge cases + (dispose eviction) to get right; scope creep for a transport package. +- **B — Leave to consumers, document the pattern** in ADVANCED.md. + - Pros: keeps the package small and unopinionated ("pluggable, not bloated"). + - Cons: everyone reimplements it. + +**Recommendation: B for the preview** (document it), and revisit A only if +multiple consumers converge on the same reimplementation. Keeping the package +lean is more aligned with the design's stated scope than pre-emptively owning +panel-registry state. + +### R766-N07: Low - `useConfiguration` can crash the webview on malformed initial data + +Reference: [packages/vscode-ext-webview/src/react/useConfiguration.ts](../../../../packages/vscode-ext-webview/src/react/useConfiguration.ts#L23-L29) + +`JSON.parse(decodeURIComponent(window.config?.__initialData ?? '{}'))` runs in a +`useState` initializer. If `__initialData` is malformed (a consumer hand-rolling +the HTML, or the R766-N03 escaping edge), the parse throws _during render_ and +the webview white-screens with no guidance. The host normally controls the +encoding, so risk is low. + +**Options** + +- **A — Defensive parse:** try/catch, return a typed default (`{} as T`) and + `console.error` on failure. + - Pros: a bad payload degrades to empty config instead of a blank webview. + - Cons: masks a malformed payload (mitigated by the logged error). +- **B — Leave (host-controlled).** + - Pros: no change. + - Cons: a single bad byte from a non-facade consumer bricks the view. + +**Recommendation: A.** + +### R766-N08: Low - The `telemetry` controller option is actually a dispatch _logger_, overloading the word + +Reference: [packages/vscode-ext-webview/src/host/WebviewController.ts](../../../../packages/vscode-ext-webview/src/host/WebviewController.ts#L92-L99) + +`WebviewControllerOptions.telemetry?: ProcedureLogger` is the zero-config console +_logging_ sink. But ADVANCED.md uses "telemetry" for the _analytics_ path +(`telemetryMiddlewareBody` + `TelemetryRunner`), which is a different mechanism +wired onto procedures. Naming the logger option `telemetry` collides with the +analytics vocabulary and will mislead agents into thinking they wire Application +Insights here. + +**Options** + +- **A — Rename the option to `logger`** (or `dispatchLogger`); keep `telemetry` + as a deprecated alias for the preview window. + - Pros: aligns with `ProcedureLogger` / `consoleProcedureLogger`; removes the + collision; preview is explicitly free to rename. + - Cons: one rename across the facade + docs + the DocumentDB consumer. +- **B — Keep the name, clarify the docs.** + - Pros: no code churn. + - Cons: the type says `ProcedureLogger` but the key says `telemetry`; the + collision remains. + +**Recommendation: A** (rename to `logger`) while still in preview. + +## Consolidated priority & decisions + +Severity legend: **Med** = fix before preview goes wider; **Low** = cheap +follow-up; **Info** = judgment call. **Status** column added after Iteration 1 +(commit for shipped fixes; see the [change protocol](#iteration-1--change-protocol-2026-07-03)). + +| ID | Severity | Area | Recommended option | Status | +| -------- | -------- | ------------------ | --------------------------------------------- | ---------------------------------------- | +| R766-01 | Med | host transport | A (guard) + B (throw-safe) | ✅ `ade2ce61` (Iteration 2) | +| R766-N02 | Med | happy-path API | A (pass `WebviewTrpc` instance) | ✅ `21b0a2f7` (Iteration 2) | +| R766-02 | Med | host lifecycle | A (`dispose()` closes panel + guard) | ✅ `76172cd2` | +| R766-03 | Med | packaging | A (ship `ADVANCED.md` + `LICENSE`) | ✅ `46296ce4` | +| R766-N01 | Med | webview transport | A (structural guard) | ✅ `ade2ce61` (Iteration 2) | +| R766-S02 | Low | webview logging | A (opt-in logger, off by default) | ✅ `6363a6f2` | +| R766-N04 | Low | host primitive | A (`ReadonlyMap`) | ✅ `0bd16afa` | +| R766-N05 | Low | event channel | isolate throws + `onObserverError` (option 1) | ✅ `76227034` (Iteration 2) | +| R766-N03 | Low | security hardening | B (JSON data block, per request) | ✅ `d5748abe` (Iteration 2) | +| R766-N08 | Low | naming | A (rename `telemetry` → `logger`) | ✅ `76f99484` | +| R766-04 | Low | docs | A (fix ADVANCED.md) | ✅ `61a01033` | +| R766-05 | Low | consumer code | A (local const) | ✅ `c5662718` | +| R766-06 | Low | host transport | A (`return()` no arg) | ✅ `0292780` | +| R766-S03 | Low | docs + helper | A (document + ship generic) | ✅ `32859afc` | +| R766-S04 | Low | docs + instrument | A (reword) + concurrency signal | ✅ `de27b507` + `dbbf9969` (Iteration 2) | +| R766-N07 | Low | webview config | A (defensive parse) | ✅ `9a758cc5` | +| R766-S01 | Info | architecture | keep three tiers | ✅ Agreed (no change) | +| R766-N06 | Info | front-door scope | B (document, don't build yet) | ✅ `b603affd` (Iteration 2) | + +### Suggested batching + +1. **Transport hardening (one PR):** R766-01, R766-N01, R766-06, R766-N04, + R766-N05 — all in `attachTrpc.ts` / `connectTrpc.ts` / `events.ts`, with the + foreign-message tests. +2. **API ergonomics (one PR, discuss first):** R766-N02 (the caller-factory + simplification) + R766-N08 rename. This is the one that may bend the public + surface, so agree the shape before coding. +3. **Lifecycle + packaging (one PR):** R766-02, R766-03. +4. **Docs + polish (one PR):** R766-04, R766-05, R766-S02, R766-S03, R766-S04, + R766-N03, R766-N07. + +Only batches 1–3 are worth treating as preview blockers; batch 4 can trail. + +--- + +# Iteration 2 — open items & answers + +Added 2026-07-03, after the Iteration 1 commits. This chapter collects everything +still open, and answers the specific questions raised while triaging iteration 1. +Nothing here is implemented yet — these are decisions and analyses to act on in a +follow-up pass. + +## Still open + +| ID | Title | Why it is here | +| -------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R766-01 | `attachTrpc` foreign-message guard | ✅ Implemented in Iteration 2 (structural guard + throw-safe listener + foreign-message test). | +| R766-N01 | Webview inbound `event.data` guard | ✅ Implemented in Iteration 2 (structural `onReceive` guard + test). | +| R766-N02 | `createCallerFactory` ergonomics | ✅ Implemented in Iteration 2 (option A: `trpc` instance option; consumer adopted; `createCallerFactory` deprecated). | +| R766-N03 | Inline-script hardening | ✅ Implemented in Iteration 2 (option B: inert `application/json` data block + nonce'd boot parser). | +| R766-N05 | Event-observer isolation & visibility | ✅ Implemented in **Iteration 3** (option 1: always-on observer isolation + opt-in `onObserverError` sink; DocumentDB opts in via `reportObserverError`). See the [Iteration 3](#iteration-3--event-observer-isolation-r766-n05) chapter. | +| R766-N06 | Create-or-reveal helper | ✅ Documented in Iteration 2 (ADVANCED.md pattern; no package code). | +| R766-S04 | Per-operation listener design | ✅ Instrumented in Iteration 2 (concurrency gauge: `ProcedureLogger.concurrent` + accumulating telemetry). | + +## Iteration 2 — change protocol (2026-07-03) + +> Protocol of changes for iteration 2: what shipped, why, and the commit that +> carries it. Each fix is an individual commit on `dev/tnaum/webview-api-refinements`, +> pushed and acknowledged with a PR comment. No Copilot review threads applied to +> these items (the two that had threads, R766-05 / R766-06, were resolved in +> Iteration 1). + +**Post-change validation (all green):** `npm run l10n` (no drift) · `prettier` +(applied) · `eslint --quiet` (clean) · `jest` (2655 passed / 158 suites) · `tsc` +build across all workspaces (clean). + +| ID | Commit | What changed | Why (motivation) | +| -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R766-01 | `ade2ce61` | `attachTrpc` guards inbound messages (`isTransportRequestMessage`) and wraps dispatch in `try/catch` | As the bring-your-own-panel primitive it may share a bus with non-tRPC traffic; a foreign message (no `op`) threw as an unhandled rejection. Ships options A + B. | +| R766-N01 | `ade2ce61` | `connectTrpc` `onReceive` guards `event.data` before reading `.id` | Webview mirror of R766-01: a `null` / foreign `window` message threw. Shipped in the same commit so both transport edges reject foreign traffic together. | +| R766-N02 | `21b0a2f7` | `openWebview` / `WebviewController` accept a `trpc` option; standalone `createCallerFactory` deprecated; consumer + README + ADVANCED updated | Removes the re-export + silent-fallback footgun on the happy path (option A). `attachTrpc` keeps its explicit factory for embedders. | +| R766-N03 | `d5748abe` | Initial data delivered in an inert `application/json` block + nonce'd boot parser; `serializeInertJson` escapes `<` | Removes the inline-script break-out class by construction (option B). `__initialData` stays encoded, so `useConfiguration` is unchanged. | +| R766-N06 | `b603affd` | ADVANCED.md documents the create-or-reveal pattern (consumer-side `Map` + `revealToForeground` + `onDisposed`) | Keeps the panel registry in consumer space; the package stays a transport library. Documented, not built. | +| R766-S04 | `dbbf9969` | `ProcedureLogEntry.concurrent` stamped by `attachTrpc`; DocumentDB `rpcConcurrencyLogger` → accumulating-telemetry `concurrentRpcOps` distribution + `dispatch` counter | Turns “revisit only on evidence” into a real concurrency signal (peak / average in-flight ops) to judge the per-operation listener. | + +**Iteration 2 wrap-up:** every item batched for Iteration 2 is addressed. The one +remaining deferral, R766-N05, was implemented separately as +[Iteration 3](#iteration-3--event-observer-isolation-r766-n05); the Copilot +reviewer's 2026-07-05 pass is triaged in +[Iteration 4](#iteration-4--copilot-reviewer-feedback-2026-07-05). + +## R766-01 — side effects on existing projects, and A/B/C re-analysis + +> ✅ **Implemented in Iteration 2** [R766-01]. Shipped **A + B** together in +> `attachTrpc`: a structural `isTransportRequestMessage` guard drops any inbound +> payload that is not a well-formed transport request (a non-null object with an +> `id` string and an `op` object whose `type` is a string), and the top-level +> `async` listener now wraps dispatch in `try/catch` so no handler throw can +> escape as an unhandled rejection. Added a foreign-message unit test (send junk / +> `null` → ignored and nothing posted; a later query still resolves). The analysis +> below is retained as the rationale. + +**Does skipping this hurt existing projects? No.** R766-01 is entirely inside +`attachTrpc`, the _bring-your-own-panel_ primitive. Today the only consumer +(DocumentDB) never calls `attachTrpc` directly — it goes through `openWebview` / +`WebviewController`, which **create and own** the panel. A framework-owned panel +carries **only** tRPC traffic, so `message.op` is always present and the missing +guard is never reached. The defect is latent until someone attaches tRPC to a +panel that _also_ carries their own `postMessage` protocol (a legacy-migration +embedder such as Cosmos). So your read is correct: it is hidden behind the API, +and there is **no real cost to current consumers** — which is exactly why it is +safe to defer. + +When it _is_ reached, the blast radius is still bounded: the throw happens inside +an `async` listener, so it becomes an unhandled rejection (log noise) rather than +a crash, it does not corrupt the tRPC channel, and it does not break the +embedder's own separate listener (VS Code fans each message out independently). +That is why the severity is Medium, not High. + +Re-analysis of the three options: + +- **A — runtime guard before the `switch`** (ignore anything that is not an + object with an `op` of a known transport type; optionally debug-log via the + existing `ProcedureLogger`). + - Pros: ~5 lines; mirrors the proven Cosmos guard; unblocks the embedder use + case; trivially unit-testable (send junk, assert a later query still + resolves). + - Cons: silent-ignore could mask a genuinely malformed tRPC message unless the + debug log is wired. + - Verdict: **still the primary fix.** +- **B — make the top-level listener throw-safe** (wrap dispatch so a handler + throw cannot escape as an unhandled rejection). + - Pros: eliminates the entire unhandled-rejection class, not just this + instance; cheap. + - Cons: needs A for the actual routing decision; on its own it only hides + symptoms. + - Verdict: **do it together with A** as defence-in-depth. +- **C — namespace the wire protocol** (wrap every framework message in a + discriminator). + - Pros: unambiguous; future-proof if a panel ever multiplexes channels. + - Cons: **breaking wire change** across `vscodeLink` + host; violates the + "wire protocol deliberately unchanged" anti-churn line; overkill for preview. + - Verdict: **reject for preview**; revisit only if real multiplexing appears. + +**Iteration 2 plan:** ship **A + B** together (with the foreign-message test) +when the first bring-your-own-panel embedder is on the horizon, or proactively +before `attachTrpc` is advertised widely — whichever comes first. + +## R766-N01 — bundle with R766-01 + +> ✅ **Implemented in Iteration 2** [R766-N01]. `connectTrpc`'s `onReceive` now +> guards `event.data` (`data !== null && typeof data === 'object' && 'id' in +data`) before forwarding, so a `null` / primitive / foreign `window` message can +> no longer throw. Covered by a new test that delivers `null` / a string / an +> `id`-less object mid-flight and asserts no throw and that the real response +> still resolves. + +Same shape on the webview side: `connectTrpc`'s `onReceive` reads +`(event.data as …).id` and throws if `event.data` is `null`/`undefined`. Same +"no current cost" reasoning (DocumentDB's webviews do not receive non-tRPC +`window` messages in production), and the same fix window. Ship the structural +guard (`data && typeof data === 'object' && 'id' in data`) **in the same pass as +R766-01** so both transport edges reject foreign traffic consistently. + +## R766-N02 — consumer code: today vs option A vs option B + +> ✅ **Implemented in Iteration 2** [R766-N02]. `openWebview` / `WebviewController` +> gained a `trpc` option; the dispatcher reads `trpc.createCallerFactory` off it. +> The standalone `createCallerFactory` option is now `@deprecated` (still honored), +> and the low-level `attachTrpc` primitive keeps its explicit `callerFactory` +> argument for embedders. DocumentDB's `openAppWebview` now passes `trpc` instead +> of re-exporting `createCallerFactory`. Added an `openWebview` test that wires the +> factory purely from the `trpc` option; README + ADVANCED.md updated. +> +> **Minor deviation from the literal `trpc: WebviewTrpc<Ctx>` plan** (confidence +> +> > 80%, verified by a clean consumer typecheck): the option is typed +> > `Pick<WebviewTrpc<TContext>, 'createCallerFactory'>`. The reference consumer +> > builds procedures on a _base-context_ instance and narrows `ctx` per call, so +> > its instance context is a base of the controller `TContext`; a strict +> > `WebviewTrpc<TContext>` would reject it. The controller only ever reads +> > `createCallerFactory`, and the `Pick` accepts that instance by parameter +> > contravariance — no cast, and the mismatch-proofing is unchanged. + +**Decision: option A** (accept the `WebviewTrpc` instance). Rationale below, +including why A and B look identical at the call site but are not, and what each +means for consumers who want the tRPC transport only, without the package's +webview scaffolding. + +### What N02 is actually solving + +The host dispatcher needs **two** things from the consumer, not one: the +`router` (what procedures exist) and a `createCallerFactory` (how to invoke a +procedure against a context). In tRPC, `createCallerFactory` is bound to the +_instance_ returned by `initTRPC.context<T>().create()` — the router object does +**not** carry a reference back to its own factory. So today the consumer has to +route that factory by hand, and if they don't, `attachTrpc` silently falls back +to `defaultCreateCallerFactory` (the factory of a _different_, bare +`BaseRouterContext` instance). That "works" for vanilla configs but is +type-unsound and misbehaves the moment the two instances differ (transformers, +error formatters). **That silent fallback is the footgun — not the verbosity.** + +**Today** (three coordinated steps; the footgun is forgetting step 2/3): + +```ts +// appRouter.ts +const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<RouterContext>(); +export const appRouter = router({ + /* … */ +}); +export { createCallerFactory }; // (1) must remember to re-export + +// open site +openWebview(ctx, { + router: appRouter, + createCallerFactory, // (2) must remember to pass it, and (3) it must match appRouter's instance + context, + config, + sourceLayout, +}); +``` + +**Option A — pass the typed tRPC instance; the factory rides along.** One import +(`trpc`), no separate factory export, and the factory cannot be mismatched +because it is read off the same instance the router was built from: + +```ts +// appRouter.ts +export const trpc = initWebviewTrpc<RouterContext>(); +export const appRouter = trpc.router({ + /* … */ +}); + +// open site — no `createCallerFactory` anywhere +openWebview(ctx, { + trpc, // openWebview reads trpc.createCallerFactory internally + router: appRouter, + context, + config, + sourceLayout, +}); +``` + +**Option B — stamp the factory onto the router; the simplest possible call.** +`initWebviewTrpc().router(...)` tags the built router with its own caller factory +(a non-enumerable symbol), and `openWebview` / `attachTrpc` read +`router[callerFactory] ?? default`. The consumer never sees `createCallerFactory` +at all: + +```ts +// appRouter.ts +const { router, publicProcedure } = initWebviewTrpc<RouterContext>(); +export const appRouter = router({ + /* … */ +}); // router silently carries its caller factory + +// open site — one thing, impossible to mismatch, no footgun +openWebview(ctx, { + router: appRouter, + context, + config, + sourceLayout, +}); +``` + +### Why A and B look similar — and where they diverge + +Both delete the re-export and the separate `createCallerFactory` argument, so at +the `openWebview` call site they read almost identically. The real difference is +the **source of truth** for the factory and **what mismatch remains possible**: + +| | Where the factory lives | Passed to `openWebview` | Can it still be mismatched? | Cost | +| --------- | --------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| **Today** | free-floating value | `router` + `createCallerFactory` | Yes — forget it / pass the wrong one → silent wrong default | ceremony + footgun | +| **A** | on the **instance** (`trpc`) | `router` + `trpc` | Only if you pass a `router` built from a _different_ instance than `trpc` (unlikely, but still two things kept in sync) | one meaningful import; explicit; no magic | +| **B** | on the **router** (hidden symbol) | `router` only | No — the router _is_ the source of truth; impossible for a simple router | zero ceremony; relies on a non-enumerable property | + +So A is a _modest_ step past today (you still hand over two coordinated things — +`trpc` and `router` — you have just swapped a loose function for the instance it +came from and dropped the dedicated re-export). B is a _qualitative_ step (one +thing, mismatch structurally impossible) at the cost of "magic": a non-enumerable +symbol stamped on the router by `initWebviewTrpc().router(...)`. + +### What each means for tRPC-only consumers (bring-your-own webview) + +This is the deciding lens. Consider a consumer who uses only the transport +primitives (`attachTrpc` on the host, `connectTrpc` in the webview) and brings +their own UI / bundling / React — not `openWebview` / `WebviewController` / the +React `render` scaffold. They split further on whether they build their tRPC root +with the package's `initWebviewTrpc()` or with raw `@trpc/server` `initTRPC()`. + +- **Option A does essentially nothing for them.** A is sugar on the `openWebview` + front door they are not using. They call + `attachTrpc(panel, ctx, router, callerFactory)` directly, and `attachTrpc` + keeps its explicit `callerFactory` parameter. The factory stays a visible + argument; nothing regresses, nothing is hidden. +- **Option B helps them only if they adopt `initWebviewTrpc().router`.** If they + build with the stamped `.router`, the factory rides on the router into + `attachTrpc` and the low-level call needs no factory argument. But if they use + **raw `initTRPC()`** — a perfectly reasonable stance for a "just the transport, + no dependency on your wrapper" consumer — the router has no stamp and they must + pass `callerFactory` explicitly (identical to today). Worse, B introduces a + **hidden-property convention**, and this cohort (advanced tRPC users) is the + most likely to hit the cases that _drop_ it: `mergeRouters`, object + spreads/clones, or wrapping the router through their own machinery can strip a + non-enumerable symbol, silently reinstating the default factory — the exact + footgun, now invisible. B's "impossible to mismatch" guarantee holds for the + greenfield router but **weakens precisely for the compose-your-own-router power + user.** + +For the bring-your-own-UI audience, then, B's implicit magic is a liability and +A's explicitness is a _feature_: composition-proof, nothing to lose, visible at +the call site. + +### Recommendation: A + +Choose **A** — accept the `WebviewTrpc<Ctx>` instance on `openWebview` / +`WebviewController`, read `trpc.createCallerFactory` internally, and **keep +`attachTrpc`'s explicit `callerFactory` parameter** for embedders. This removes +the re-export and the silent-fallback footgun for the greenfield path without +introducing a hidden property that can be silently dropped by router +composition. B is rejected not because its call site is worse — it is marginally +nicer — but because its one advantage (zero-argument magic) is bought with an +implicit convention that is least reliable for exactly the tRPC-only, +bring-your-own-router consumers this package also serves. `attachTrpc` continues +to accept an explicit `callerFactory` for embedders who bring their own tRPC +instance. + +Concretely, A means: `openWebview` / `WebviewController` gain an optional +`trpc: WebviewTrpc<Ctx>` option; when present the dispatcher uses +`trpc.createCallerFactory`; the standalone `createCallerFactory` option is +deprecated on the happy path, while `attachTrpc` still accepts an explicit +`callerFactory` positional for raw-tRPC embedders. No wire change, no hidden +state. + +## R766-N03 — option B (JSON data block): consumer side effects + +> ✅ **Implemented in Iteration 2** [R766-N03]. `WebviewController.getDocumentTemplate` +> now emits the initial data (encoded config, l10n bundle, viewType) in an inert +> `<script type="application/json" id="vscode-ext-webview-initial-data">` block and +> reads it from a nonce'd module boot script (`JSON.parse(el.textContent)` → +> `globalThis.l10n_bundle` / `window.config.__initialData` / `render(viewType)`). +> A `serializeInertJson` helper escapes `<` (plus U+2028 / U+2029) so the block +> cannot break out of its `</script>`. `__initialData` stays +> `encodeURIComponent`'d, so `useConfiguration` is unchanged. Added a regression +> test asserting an injected `</script>` in `viewType` is escaped to `\u003c`. +> Standard-template consumers (all of them, via `WebviewController`) are +> unaffected. + +**Option B** replaces the executable inline-script injection with a non-executed +data block: + +```html +<!-- instead of: <script>window.config = { __initialData: '…' }; l10n_bundle = {…}</script> --> +<script type="application/json" id="vscode-ext-webview-initial-data"> + { "config": …, "l10n": … } +</script> +``` + +…and a tiny nonce'd boot script that `JSON.parse`s that element into +`window.config` / `globalThis.l10n_bundle` before calling `render(...)`. + +**Side effects on consumers — essentially none for the standard path:** + +- `useConfiguration` still reads `window.config.__initialData`; the standard + `render(viewType, vscodeApi)` scaffold is unchanged. Consumers using the + package's HTML template (all of them today, via `WebviewController`) see **no + API change**. +- The only consumers affected are those who **hand-roll their own HTML** instead + of the template — they would adopt the data-block convention. We own the + template and the one consumer (DocumentDB), and the API is unshipped, so the + blast radius is effectively zero. +- CSP: the JSON block is inert (`type="application/json"` is not executed), so it + needs no `script-src` entry; only the small parser boot script needs the + existing nonce. Net CSP change is negligible. +- Payoff: the `</script>` / `U+2028` / quote break-out edge in R766-N03 + disappears by construction, and it composes well with the already-shipped + defensive parse (R766-N07). + +**Recommendation:** adopt **B** in iteration 2. Because we can update our own +consumers and the standard path is untouched, B is cleaner than option A's +manual escaping and removes the vulnerability class rather than patching it. + +## R766-N05 — options to make observer exceptions visible to telemetry + +> ✅ **Implemented in Iteration 3** [R766-N05]. Both parts shipped together: the +> **correctness fix** (a throwing observer is now isolated so it can no longer +> break the tRPC call it only observes) and the **visibility hook** — **option 1**, +> an opt-in `onObserverError` sink defaulting to `console.error`. It stays off +> beyond that console default in the happy path; DocumentDB opts in via +> `reportObserverError`, which also elevates to the browser `reportError()` global +> (**option 5**). See the +> [Iteration 3](#iteration-3--event-observer-isolation-r766-n05) chapter for the +> change protocol and rationale; the option analysis below is retained as the +> record of why option 1 was chosen. + +Goal: when a consumer's event-channel handler throws, it must (a) not corrupt tRPC +dispatch and (b) be _observable_ — ideally routable to telemetry, not just +`console.error`. Options (not implemented; for discussion): + +- **Option 1 — an `onObserverError` sink.** `createEventChannel({ onError })` / + `connectTrpc(api, { onObserverError })`; the channel try/catches each handler + and calls the sink with `(error, info)`. Consumers forward it to their + telemetry. _Pros:_ explicit, structured (keeps `CallInfo`), testable, isolates + the throw. _Cons:_ one more option. +- **Option 2 — a channel `onInternalError` event.** Add it to `RpcEventChannel`. + _Cons:_ its own handlers can throw (recursion) — needs a hard guard; muddies the + "observe query/mutation outcomes" contract. +- **Option 3 — synthesize an `emitError` with a marker path** (e.g. `$observer`). + _Cons:_ pollutes the normal error stream; confusing to consumers. +- **Option 4 — round-trip to the host** so host-side telemetry records it. + _Cons:_ heavy; couples webview observer bugs to host telemetry; lossy/ordered. +- **Option 5 — `reportError(err)` (the browser global).** Wrap handler calls so a + throw is reported; surfaces in devtools and is catchable by a consumer's global + handler. _Pros:_ zero API surface; standard mechanism. _Cons:_ unstructured (no + `CallInfo`). + +**Leaning:** **Option 1** as the structured, opt-in hook (default it to +`console.error` so it also fixes the dispatch-corruption in R766-N05), optionally +combined with **Option 5** as the always-on default so a throw is at least +visible without wiring. Decide in iteration 2. + +## R766-S04 — pros & cons of the per-operation `message` listener (shipped: doc reword only) + +> ✅ **Instrumented in Iteration 2** [R766-S04]. The design is unchanged (still the +> per-operation listener), but it is now measured. `attachTrpc` stamps every +> dispatch log entry with `concurrent` (in-flight ops at completion), and +> DocumentDB's `rpcConcurrencyLogger` feeds it into +> `callWithAccumulatingTelemetry('documentDB.webview.rpcConcurrency', …)` as a +> `concurrentRpcOps` distribution plus a `dispatch` counter (batched, so volume is +> negligible). The revisit thresholds are in the “Instrument it” subsection below. +> This turns “revisit only on evidence” into an actual signal — and, if peak `N` +> stays tiny, the evidence to _retire_ S04. + +The transport (`vscodeLink` → `connectTrpc`'s `onReceive`) registers a **new +`window` `message` listener per in-flight operation**, each filtering by +`operationId` and removed on completion, rather than one central listener with an +`id → observer` map. The README reword (R766-S04) stopped _advertising_ this +internal; here is why the design itself is reasonable and when to revisit it. + +**Pros** + +- **Simplicity & correctness by construction.** Each operation's observable owns + its listener and its teardown, so add-on-subscribe / remove-on-unsubscribe is + local and hard to get wrong. No shared routing table to keep in sync. +- **Isolation.** No central mutable map that a bug (or a consumer, cf. R766-N04) + can corrupt; one operation cannot disturb another's routing. +- **Leak-resistant.** Cleanup is tied to observable teardown; unsubscribing an + operation removes exactly its listener. + +**Cons** + +- **O(N) fan-out.** Every inbound message is delivered to all N current listeners, + each doing an id check — O(N) per message, O(N·M) for M messages. For a webview + with many concurrent in-flight operations this is wasteful. +- **Listener churn.** Rapid `addEventListener` / `removeEventListener` cycles for + short operations. +- **Repeated guard.** The `event.data` guard (R766-N01) runs in every listener. + +**Assessment.** For this package's domain — interactive VS Code webviews with a +handful of concurrent RPCs — the per-operation listener is a sound +simplicity-over-throughput trade, and the source comment already says as much. A +single multiplexing listener + `Map<id, observer>` is the O(1)-per-message +"textbook" alternative and would be worth adopting **only** if the fan-out is +shown to matter under real concurrency. **Recommendation: keep as is, but +instrument it so the "revisit" trigger is data, not a hunch.** The README reword +already shipped; the concurrency signal below is the Iteration 2 ask. + +### Instrument it — a concurrency signal to decide on evidence + +The fan-out cost is `O(N)` per message, where `N` is the number of **concurrent +in-flight operations** (each one owns a `window` `message` listener). `N` is the +single variable that decides whether the per-operation design ever matters, so +that is what we measure: its **peak** and **average**, plus the dispatch volume +that multiplies it. + +**Where to sample it (for free).** The host already tracks every in-flight +operation in [`AttachTrpcResult.activeOperations` + `.activeSubscriptions`](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L55-L78) +(exposed read-only in R766-N04). Their combined size _is_ `N`. Because each +in-flight operation is exactly one host map entry **and** one webview listener, +the host-side count is a faithful, zero-cost proxy for the webview fan-out — no +webview→host round-trip needed. + +**How to emit it — reuse the accumulating telemetry.** +[`callWithAccumulatingTelemetry`](../../../../src/utils/accumulatingTelemetry.ts) +already reduces a `distributions` gauge to `min / max / sum / count` across a +batch and sums plain `measurements`, so one call per dispatched operation gives +us the whole picture without per-op event spam: + +```ts +// host side, once per dispatched operation — given the AttachTrpcResult handle +// (or, cleaner, the ProcedureLogger `concurrent` field proposed below) +void callWithAccumulatingTelemetry('documentDB.webview.rpcConcurrency', (ctx) => { + const n = handle.activeOperations.size + handle.activeSubscriptions.size; + (ctx.telemetry as TelemetryWithDistributions).distributions.concurrentRpcOps = n; // gauge → min/max/sum/count + ctx.telemetry.measurements.dispatch = 1; // summed → total ops per flush window +}); +``` + +Optional tiny package hook to make this first-class instead of reaching into the +maps: add a `concurrent` field to the `ProcedureLogger` log record (= +`activeOperations.size + activeSubscriptions.size` at log time) — a natural +extension of R766-N04's "expose for observation." Any consumer's logger can then +forward it into their own telemetry. + +**Fields emitted** (batched — default 20 calls / 30 s): + +- `dist_concurrentRpcOps_max` — **peak concurrency; the number that matters** (the + high-water mark of simultaneous listeners). +- `dist_concurrentRpcOps_sum` / `_count` — average concurrency = `sum / count`. +- `dispatch` — total operations per flush window (volume / a rough rate over ~30 s). +- `dist_auto_duration_ms_*` — per-op latency, recorded for free by the wrapper. + +**Guidelines — what reading sends us back to this.** The thresholds follow +directly from the `O(N)` model: at small `N` the per-message work is a few +id-string comparisons; the multiplexer's extra complexity — a shared routing +table with the very corruption surface R766-N04 guards against — only pays off +once `N` is routinely large or sustained-high **and** the message rate is high +(the `O(N·M)` term bites). + +| Reading | Interpretation | Verdict | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| `dist_concurrentRpcOps_max` ≤ 8 in ~all sessions | fan-out is a handful of comparisons per message | **Keep — and consider _retiring_ S04**: the concern is disproven | +| occasional `_max` in 8–32 | spikes, not sustained | keep as is; leave the signal on | +| `_max` ≥ 32 in ≳ 1 % of webview sessions, **or** average (`_sum / _count`) ≥ 8 sustained — especially if `dispatch`/window is also high (heavy streaming) | genuine, sustained fan-out | **Revisit**: build the single-listener + `Map<id, observer>` multiplexer | + +The signal is designed as much to **retire** S04 as to trigger work: if peak `N` +stays tiny across the fleet (the expected outcome for interactive webviews), we +close S04 permanently instead of carrying it as a perennial "maybe." Keep the +event cheap — it is a gauge sample, so do **not** `suppressIfSuccessful` (we need +the successful samples), and let the 20-call / 30-second batching keep the volume +negligible. + +## R766-N06 — create-or-reveal (unchanged) + +> ✅ **Documented in Iteration 2** [R766-N06]. Added a "Create-or-reveal +> (single-instance panels)" section to ADVANCED.md showing the consumer-side +> `Map<key, controller>` + `revealToForeground()` + `onDisposed()` eviction +> pattern. No package code — the registry stays in consumer space, per the +> recommendation below. + +No change from the first pass: **document** the create-or-reveal pattern in +ADVANCED.md rather than building a panel registry into the package. Revisit only +if multiple consumers reimplement it. Keeping the package lean is more aligned +with its stated scope than owning panel-lifecycle state. + +# Iteration 3 — event-observer isolation (R766-N05) + +Added 2026-07-05. This chapter records the one item Iteration 2 deferred: the +implementation of **R766-N05**. Interim edits had folded it into the Iteration 2 +protocol; it is a distinct pass and is documented here as its own iteration. + +## What shipped + +R766-N05 bundled a **correctness fix** and a **visibility hook**; Iteration 3 +shipped them together, adopting **option 1** from the +[R766-N05 option analysis](#r766-n05--options-to-make-observer-exceptions-visible-to-telemetry). + +- **Always-on isolation (correctness).** `createEventChannel` now wraps every + `onSuccess` / `onError` / `onAborted` observer so a throwing observer can no + longer break the tRPC call it was only _observing_. This upholds the + observer-only contract regardless of consumer configuration — it is not opt-in. +- **Opt-in `onObserverError` sink (visibility).** The isolated error is routed to + an `onObserverError(error, { info, phase })` sink that **defaults to + `console.error`**, threaded through `connectTrpc` and the React + `WithWebviewContext` / hooks. Beyond that console default the happy path stays + quiet — a generic consumer is not opted into telemetry or events it may not + want. +- **DocumentDB opts in for itself.** + [`reportObserverError`](../../../../src/webviews/_integration/observability/reportObserverError.ts) + keeps the structured `console.error` (path + phase) and additionally elevates + the error to the webview's browser `reportError()` global — the "general + observability" of option 5 — without re-entering the tRPC channel, so a throwing + observer cannot cause a report loop. + +## Change protocol (2026-07-05) + +> Each change is an individual commit on `dev/tnaum/webview-api-refinements`, +> pushed and acknowledged with a PR comment. No Copilot review thread applied +> (R766-N05 was a Reviewer-2 finding, not a Copilot one). + +**Post-change validation (all green):** `prettier` (applied) · `eslint --quiet` +(clean) · `jest` (2661 passed / 159 suites) · `tsc` build across all workspaces +(clean). + +| Commit | What changed | Why (motivation) | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `76227034` | `createEventChannel` isolates throwing observers and routes them to an `onObserverError` sink (default `console.error`); threaded through `connectTrpc` and `WithWebviewContext` / the hooks; adds `ObserverErrorPhase` / `ObserverErrorContext` / `ObserverErrorHandler` types | A throwing observer previously broke the tRPC call it observed. Option 1: an always-on correctness fix plus an opt-in structured sink. | +| `559a9af1` | Recorded the R766-N05 implementation in this review doc | Traceability. | +| `f7c96564` | `reportObserverError` calls the typed `globalThis.reportError` behind a `typeof` guard instead of a `(globalThis as { reportError?: … })` cast | `reportError` is declared in `lib.dom` (the webview tsconfig includes `dom`), so the cast was unnecessary. No behavior change. | + +## `reportError` naming: the DOM global, not the app-router mutation + +DocumentDB's app router also exposes a `reportError` **tRPC mutation** (webview → +host telemetry). The sink's `globalThis.reportError` is the unrelated **browser +DOM global** ([`reportError()`](https://developer.mozilla.org/docs/Web/API/reportError), +which dispatches an `ErrorEvent` on `window`). The shared name is a coincidence: +the sink never calls the tRPC mutation, which is exactly why it is loop-safe — +elevating an observer error to the DOM error stream cannot re-enter the tRPC event +channel that produced it. + +## Deferred: routing observer errors to host telemetry + +If DocumentDB later wants observer errors in **host** telemetry (not just the DOM +error stream), the route is the app router's `reportEvent` / `reportError` +mutation, but it needs two guards: + +- **Path-guard against recursion.** Sending an observer error through a tRPC + mutation re-enters the same event channel; if that mutation's own observer + throws, it loops. Skip the `reportEvent` / `reportError` paths, or use a + separate, un-observed client whose links are `vscodeLink` only (no `eventLink`). +- **Dedupe / throttle.** Key on `path|phase|message` so a hot, repeatedly-throwing + observer cannot flood telemetry. + +This is intentionally **not** implemented; the console + `reportError()` sink is +the current floor. + +# Iteration 4 — Copilot reviewer feedback (2026-07-05) + +Added 2026-07-05. On this date the GitHub Copilot PR reviewer ran a second +automated pass (review `4630986353`): it reviewed 92 of 99 changed files and left +**3 inline comments** plus **1 low-confidence comment it self-suppressed**. This +chapter captures each, assigns a tracking ID (`R766-Cnn`, `C` for Copilot-sourced), +and analyzes the options. **All four items were implemented in this iteration** +(change protocol below); the per-finding analyses are retained as the rationale. + +## Overview + +| ID | Source | Sev. | Title | +| -------- | --------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| R766-C01 | [`telemetryMiddlewareBody`](../../../../packages/vscode-ext-webview/src/host/middleware/telemetry.ts#L119-L153) | Low | Telemetry middleware records error details for aborted calls | +| R766-C02 | [`documentDbTelemetryRunner`](../../../../src/webviews/_integration/trpc.ts#L90-L118) | Low | DocumentDB runner overwrites error fields for canceled ops | +| R766-C03 | [`connectTrpc.onReceive`](../../../../packages/vscode-ext-webview/src/webview/connectTrpc.ts#L109-L123) | Low | Webview `onReceive` guard accepts any `id` (prototype / non-string) | +| R766-C04 | [`useTrpcClient`](../../../../src/webviews/_integration/useTrpcClient.ts#L18-L20) | Info | `useTrpcClient` wrapper lacks an explicit return type (suppressed) | + +**Themes.** C01 + C02 are the same defect on two layers — canceled operations get +tagged with error fields — and must be fixed together (C02 would otherwise undo +C01). C03 tightens a guard shipped in Iteration 2 (R766-N01) so the webview edge +matches the host. C04 is a typing nicety that aligns with this repo's own "always +specify return types" rule. + +## Iteration 4 — change protocol (2026-07-05) + +> Each fix is an individual commit on `dev/tnaum/webview-api-refinements`, pushed +> and acknowledged on the PR — a reply on the originating Copilot review thread +> (C01–C03, all resolved) or a general comment (C04, which had no thread). C01 and +> C02 shipped in one commit because C02's enrichment would otherwise undo C01. + +**Post-change validation (all green):** `npm run l10n` (no drift) · `prettier` +(clean) · `eslint --quiet` (clean) · `jest` (2662 passed / 159 suites) · `tsc` +build across all workspaces (clean). + +| ID | Commit | What changed | Why (motivation) | +| -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| R766-C01 | `553bf4e8` | `telemetryMiddlewareBody` gates the whole error-recording block on `!aborted`; `getInvocationSignal` is exported from `./host`; regression test added | An aborted call that surfaced as a rejected result was tagged `Canceled` yet still stamped `error` / `errorMessage`, so a cancellation looked like a failure on the error dimension. | +| R766-C02 | `553bf4e8` | `documentDbTelemetryRunner` reads `getInvocationSignal` and skips its `parseError` enrichment when the call was aborted | The enrichment ran after the body on the same telemetry bag and re-stamped `error*` for canceled ops, undoing C01. Shipped in the same commit. | +| R766-C03 | `3301a709` | Shared `isTransportResponseMessage` guard (own string `id`) in `wireProtocol.ts`, used by `connectTrpc`'s `onReceive`; the R766-N01 test was extended | The webview guard forwarded any object with an `id` (inherited or non-string) — looser than the host guard; tightened for host/webview symmetry. | +| R766-C04 | `19b99cbf` | `useTrpcClient` wrapper return type annotated as `TrpcClient<AppRouter>` | Locks the public surface / avoids type widening and matches the repo's explicit-return-type rule (Copilot's suppressed low-confidence note). | + +## R766-C01 — telemetry middleware records error details for aborted calls + +> ✅ **Implemented in Iteration 4** [R766-C01] (option A). `telemetryMiddlewareBody` +> now gates the whole error-recording block on `!aborted`, so an aborted call is +> recorded only as `Canceled` (with `aborted='true'`) and never carries `error` / +> `errorMessage`. A regression test drives an aborted invocation that also throws +> and asserts no `error*` fields are stamped. The analysis below is retained as the +> rationale. + +**Copilot:** `telemetryMiddlewareBody` records `error` / `errorMessage` even when +the invocation was aborted, which makes canceled operations look like failures and +contradicts the nearby "recorded as `Canceled`" comment. Only record error details +when `!aborted`. + +**Current state — the classification is already correct; the error _fields_ are +not.** +[`telemetryMiddlewareBody`](../../../../packages/vscode-ext-webview/src/host/middleware/telemetry.ts#L119-L153) +sets `result = 'Failed'` only when `!aborted`, so an aborted call is already +labeled `Canceled`. What still leaks is the error **name and message**, because +that block sits under `if (!result.ok)` but not under `if (!aborted)`: + +```ts +if (aborted) { + telemetry.properties.aborted = 'true'; + telemetry.properties.result = 'Canceled'; +} +if (!result.ok) { + if (!aborted) { + telemetry.properties.result = 'Failed'; + } + if (result.error?.name) telemetry.properties.error = result.error.name; // ← still runs when aborted + if (result.error?.message) telemetry.properties.errorMessage = result.error.message; // ← still runs when aborted +} +``` + +A cancellation that surfaces as a rejected result (the awaited work throws an +`AbortError` when the signal fires) is therefore tagged `result=Canceled` **and** +`error=AbortError` — indistinguishable from a real failure on the error dimension. + +**Options.** + +- **Option A — move the two `error*` writes under `!aborted`.** Aborted calls then + record only `result=Canceled` + `aborted=true`. Minimal, matches Copilot, and + matches the existing `result` handling. _Con:_ if an operation is aborted **and** + fails for an unrelated reason (a genuine bug racing with cancellation), the error + name is dropped. +- **Option B — record the aborted error under a distinct key** (e.g. + `properties.abortError`), keeping a breadcrumb without polluting the primary + `error` dimension used for failure analytics. _Con:_ one more property to define + and document. +- **Option C — suppress only the cancellation error itself** — record error details + unless the error _is_ the abort (`error.name === 'AbortError'` / matches the abort + cause). Most precise, but the generic middleware would have to recognize a + cancellation error shape, which varies by producer. + +**Recommendation: Option A in the package.** Smallest change, restores the +`Canceled` contract, and keeps the generic middleware free of error-shape +heuristics (Option C is really consumer policy). Ship it in the **same commit as +C02**. + +## R766-C02 — DocumentDB runner overwrites error fields for canceled ops + +> ✅ **Implemented in Iteration 4** [R766-C02] (option B). `getInvocationSignal` is +> now exported from `@microsoft/vscode-ext-webview/host`, and +> `documentDbTelemetryRunner` reads it to skip its `parseError` enrichment (`error` +> / `errorMessage` / `errorStack` / `errorCause`) when the call was aborted — so it +> no longer undoes R766-C01. Shipped in the same commit as C01. The analysis below +> is retained as the rationale. + +**Copilot:** `documentDbTelemetryRunner` enriches and **overwrites** telemetry +error fields for any `!result.ok`, even when canceled; guard the enrichment when +`invocation.ctx.signal?.aborted` is true. + +**Why this pairs with C01 (and why C01 alone is not enough).** The runner's +enrichment runs _after_ the middleware body, on the **same** telemetry bag +([`documentDbTelemetryRunner`](../../../../src/webviews/_integration/trpc.ts#L90-L118)): + +```ts +const result = await execute(context.telemetry as …); // the body runs here (C01) +if (!result.ok && result.error) { + // ← no abort check + const parsed = parseError(result.error); + context.telemetry.properties.error = parsed.errorType; // re-stamps, overwriting C01 + context.telemetry.properties.errorMessage = parsed.message; + context.telemetry.properties.errorStack = (result.error as { stack?: string }).stack ?? ''; + if (result.error.cause) context.telemetry.properties.errorCause = parseError(result.error.cause).message; +} +``` + +Even after C01 stops the body from writing `error*` on an aborted call, this block +**re-stamps** `error`, `errorMessage`, `errorStack`, and `errorCause`. A C01-only +fix is silently undone for DocumentDB, so the two must land together. + +**Options for reading the abort state.** + +- **Option A — inline read.** + `const aborted = (invocation.ctx as { signal?: AbortSignal }).signal?.aborted ?? false;` + then gate the block with `&& !aborted`. Zero new public surface. _Con:_ + duplicates the abort-reading logic the package already has, so the two can drift. +- **Option B — export and reuse `getInvocationSignal`.** The package has + [`getInvocationSignal(ctx)`](../../../../packages/vscode-ext-webview/src/host/middleware/types.ts#L68-L70) + but does not export it from `./host`. Export it and call + `getInvocationSignal(invocation.ctx)?.aborted`, so body and runner share one + abort check. _Con:_ a small public-API addition. +- **Option C — reuse the body's decision.** The body already wrote + `result === 'Canceled'`; the runner could skip enrichment when that is set. + _Con:_ couples the consumer to a magic string and to ordering; brittle. + +**Recommendation: Option B** — export `getInvocationSignal` and gate the whole +enrichment block (including `errorStack` / `errorCause`) on `!aborted`, so both +telemetry layers read the abort state from one helper and cannot drift. Option A +is an acceptable zero-surface fallback. Land with C01, with a test asserting an +aborted call records `result=Canceled` and **no** `error*` fields. + +## R766-C03 — tighten the webview `onReceive` guard to an own, string `id` + +> ✅ **Implemented in Iteration 4** [R766-C03] (option C). Added a shared +> `isTransportResponseMessage` guard to `shared/wireProtocol.ts` (non-null object +> with its **own** string `id`, via `Object.hasOwn` + `typeof`), mirroring the +> host-side `isTransportRequestMessage`, and `connectTrpc`'s `onReceive` now uses +> it. The R766-N01 test was extended with a numeric-`id` case and an +> inherited-`id` case (whose `id` equals the pending request id, so the old +> `'id' in data` guard would have wrongly resolved the query with `undefined`). +> The analysis below is retained as the rationale. + +**Copilot:** the `onReceive` window-message guard forwards any object with an `id` +property (including prototype properties) into the tRPC response path; require an +**own** `id` field with a **string** value. + +**Context — this refines R766-N01.** Iteration 2 added the webview-side guard; +Copilot notes it is looser than its host-side sibling: + +```ts +// webview — connectTrpc.onReceive (R766-N01, shipped) +if (data !== null && typeof data === 'object' && 'id' in data) { + /* … */ +} +// host — isTransportRequestMessage (R766-01) already checks the TYPE +typeof (message as { id?: unknown }).id === 'string'; /* …plus op shape… */ +``` + +[`VsCodeLinkResponseMessage.id`](../../../../packages/vscode-ext-webview/src/shared/wireProtocol.ts#L51-L64) +is typed `string`, yet `'id' in data` is true for an **inherited** `id` and never +checks the **value type** — so the webview edge is weaker than both the wire type +and the +[host guard](../../../../packages/vscode-ext-webview/src/host/attachTrpc.ts#L134-L149). + +**Options.** + +- **Option A — minimal type tightening.** Replace `'id' in data` with + `typeof (data as { id?: unknown }).id === 'string'`. Rejects non-string and + missing ids (a missing prop reads `undefined`), matches the host guard's rigor, + one line. _Con:_ an inherited **string** `id` would still pass — contrived for + structured-clone'd `postMessage` data. +- **Option B — own + string (Copilot verbatim).** + `Object.hasOwn(data, 'id') && typeof (data as { id: unknown }).id === 'string'`. + `Object.hasOwn` is available (tsconfig `lib` is ES2023). Most defensive; handles + the prototype case explicitly. +- **Option C — a shared `isTransportResponseMessage` guard** in + `shared/wireProtocol.ts`, mirroring `isTransportRequestMessage`, called from + `onReceive`. Best symmetry and a single source of truth for the response shape. + Keep it to "own string `id`"; do **not** also require `result` / `error` / + `complete` (a bare `{ id, complete }` ack is valid), which would over-tighten. + +**Recommendation: Option C** using Option B's "own string `id`" predicate — it +restores host/webview symmetry (each edge validates through one structural guard) +and gives the response shape a home. If minimizing churn is preferred, **Option B +inline** is a faithful one-liner. Either way, extend the R766-N01 test with +inherited-`id` and numeric-`id` cases. Resolves the Copilot thread on +`connectTrpc.ts`. + +## R766-C04 — explicit return type on the `useTrpcClient` wrapper (suppressed) + +> ✅ **Implemented in Iteration 4** [R766-C04] (option A). The DocumentDB wrapper +> now declares its return type as `TrpcClient<AppRouter>` (imported from +> `@microsoft/vscode-ext-webview/react`), locking the public surface and matching +> the repo's "always specify return types" rule. The analysis below is retained as +> the rationale. + +**Copilot (self-suppressed, low confidence):** giving the shared `useTrpcClient` +helper an explicit return type makes the public surface clearer and avoids +accidental type widening if the framework hook signature changes. + +Copilot suppressed this itself, but it matches this repo's TypeScript guideline +("**Always specify return types** for functions"). The wrapper +([`useTrpcClient`](../../../../src/webviews/_integration/useTrpcClient.ts#L18-L20)) +is a one-line pass-through with an inferred return. + +**Options.** + +- **Option A — name the concrete type.** Annotate the return as the framework + client type (e.g. `TRPCClient<AppRouter>` / whatever the framework hook returns). + Explicit, locks the public surface, honors the repo rule. _Con:_ couples to the + framework client type name; needs an import. +- **Option B — `ReturnType<typeof useFrameworkTrpcClient<AppRouter>>`.** Always + tracks the source, but reads awkwardly and still depends on the generic-call + syntax. +- **Option C — leave inferred.** Accept the self-suppression: a one-line + pass-through has a stable inferred type. _Con:_ diverges from the repo's + explicit-return-type convention. + +**Recommendation: Option A** — a one-line annotation that satisfies the repo +convention and the reviewer's intent. **Low priority**; batch with C03 (same +webview area) or take standalone. + +## Suggested batching + +- **Batch 1 — cancellation telemetry (C01 + C02), one commit.** Gate error + recording on `!aborted` in the package body _and_ the DocumentDB runner (export + `getInvocationSignal` for a shared check); add a test asserting an aborted call + records `result=Canceled` with no `error*` fields. Resolves both telemetry + comments. +- **Batch 2 — transport-guard symmetry (C03).** Own string `id` (ideally a shared + `isTransportResponseMessage`); extend the R766-N01 test. Resolves the + `connectTrpc.ts` comment. +- **Batch 3 — typing nicety (C04), optional / low priority.** Explicit return type + on `useTrpcClient`. + +Each batch was shipped under the established protocol: one commit on +`dev/tnaum/webview-api-refinements`, pushed, with a PR acknowledgment referencing +the SHA (and, for C01–C03, resolving the Copilot thread). + +## Iteration 4 — done + +All four Copilot findings from the 2026-07-05 review (`4630986353`) are +implemented, verified, and acknowledged on the PR. Steps performed: + +1. **Triaged** the review into R766-C01–C04 with options and recommendations (this + chapter). +2. **Batch 1 — C01 + C02** (`553bf4e8`): gated the telemetry body's error recording + on `!aborted`, exported `getInvocationSignal`, and made `documentDbTelemetryRunner` + skip its enrichment when aborted; added a package regression test. Replied to and + resolved the C01 and C02 Copilot threads. +3. **Batch 2 — C03** (`3301a709`): added the shared `isTransportResponseMessage` guard + (own string `id`) and wired it into `connectTrpc`'s `onReceive`; extended the + R766-N01 test with numeric-`id` / inherited-`id` cases. Replied to and resolved the + C03 Copilot thread. +4. **Batch 3 — C04** (`19b99cbf`): annotated the `useTrpcClient` wrapper return type as + `TrpcClient<AppRouter>`. No thread (self-suppressed); covered by the wrap-up PR + comment. +5. **Finalized** the doc (`cb9cd3b2`): the change protocol above plus statuses. +6. **Validated — all green:** `npm run l10n` (no drift) · `prettier` · `eslint --quiet` + · `jest` (2662 passed / 159 suites) · `tsc` build across all workspaces. + +✅ **Iteration 4 is complete.** The three inline Copilot threads (C01 / C02 / C03) +are resolved, C04 (suppressed) is addressed, and the PR branch is green — nothing +from the 2026-07-05 review remains open. + +# Iteration 5 — consumer ergonomics & onboarding (2026-07-05) + +Added 2026-07-05. Iterations 1–4 focused mostly on correctness, guardrails, and +review-thread cleanup. This pass steps back and asks a different question: once +this package works, how easy is it for a first-time consumer or a coding agent to +adopt it correctly with the least possible boilerplate? + +## Overview + +| ID | Severity | Title | Why it matters | +| -------- | -------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| R766-E01 | Medium | Greenfield adoption still requires too many moving parts | New consumers must connect router, context, config, and render bootstrap by hand, even though the package's pitch is “make webview RPC easy.” | +| R766-E02 | Low | `viewType` is duplicated across host and webview registration | The same string must be kept in sync in two places, which is easy to get wrong and hard to debug when it drifts. | +| R766-E03 | Low | The common bootstrap still feels more ceremonial than it should | The happy path is correct, but it still asks the consumer to thread `vscodeApi` and the render entrypoint together by hand. | + +## R766-E01 — the greenfield path still feels “wiring-heavy” for a first-time consumer + +> ✅ **Decision (2026-07-05): Option B — document the pattern, do not add a helper +> to the library.** The pattern E01 asks for already exists in consumer space as +> DocumentDB's [`openAppWebview`](../../../../src/webviews/_integration/openAppWebview.ts) +> (a ~15-line preset that binds the fixed wiring — `router`, `trpc`, +> `sourceLayout`, `logger` — so each per-view factory states only what is unique +> to its view). It is a proven reference implementation of E01 Option A, but kept +> where it belongs: in the consumer, not the transport package. **Action:** +> document the `openAppWebview`-style "integration preset" pattern in ADVANCED.md +> (alongside the create-or-reveal pattern from R766-N06) so new consumers can copy +> it, and **do not** ship a `createWebviewApp` / `createWebviewController` helper +> in the package. This keeps the package a lean transport library, consistent with +> the R766-N06 decision to keep panel-registry state in consumer space. The +> `trpc`/`router` mismatch footgun — the only _sharp_ edge on this path — was +> already removed in R766-N02. + +The package already has a good one-call front door, but the current happy path +still asks a consumer to connect several concepts that are logically part of a +single feature: + +1. define the router and the typed `trpc` instance; +2. pass `router` + `trpc` (plus context, config, and source layout) into + `openWebview`; +3. register the same `viewType` in the webview render registry; +4. wrap the webview root with `WithWebviewContext` and then call + `useTrpcClient` / `useConfiguration`. + +That is not a bug, but it is still a noticeable amount of “framework plumbing” +for a package whose pitch is to make webview RPC easy. The cost is not in the +API shape itself; it is in the number of steps a new consumer or coding agent +has to remember in order to make the package work end to end. + +**Options** + +- **A — add a small higher-level helper** such as `createWebviewApp`, + `createWebviewController`, or `createWebviewBootstrap` that takes the router, + context, config, and component registry and wires the host-side panel plus the + webview bootstrap in one place. +- **B — keep the current primitives and add a single copy-paste “minimal full + example”** to the README / ADVANCED.md with the host and webview side in one + file. +- **C — leave the current shape and rely on the starter kit.** + +**Recommendation:** Option A if the goal is to make the preview package feel +truly beginner-friendly; Option B is the low-risk immediate step. The package +already has the right primitives, but the “glue” between them is still too +manual for a first-time consumer. + +## R766-E02 — `viewType` is a shared key, but the package does not help keep it in sync + +> ✅ **Decision (2026-07-05): non-issue — no action.** DocumentDB already solves +> this at compile time: [`WebviewRegistry`](../../../../src/webviews/_integration/WebviewRegistry.ts) +> derives `type WebviewName = keyof typeof WebviewRegistry`, and both the host +> (`openAppWebview` / `OpenAppWebviewOptions.webviewName`) and the webview +> (`render(key, …)` in [index.tsx](../../../../src/webviews/index.tsx)) are typed +> to that union — so a drifted / mistyped `viewType` is a **compile error**, not a +> blank panel. The coupling E02 worries about is already enforced by the type +> system in the reference consumer. Nothing to do in the package; the +> registry-as-single-source-of-truth pattern is covered by the E01 documentation +> action above. + +The host-side `openWebview` call and the webview-side `render(viewType, vscodeApi)` +registry both depend on the same `viewType` string. That is the right design (the +string is the feature’s identity), but it is also one of the easiest things for a +new consumer to get wrong. A drift between the two means the wrong component +renders or nothing renders at all, and the debugging cost is high because the +failure is indirect. + +**Options** + +- **A — provide a tiny helper** that registers a component and binds a local + `viewType` in one place, rather than leaving the string as an external + convention. +- **B — document a single-source-of-truth pattern** in the README and the + starter kit (for example, centralize the map in one module and import it from + both sides). +- **C — leave it as-is.** + +**Recommendation:** Option B right away, Option A if the package wants to make +this path feel more guided. The issue is not the concept of `viewType`; it is +that the package currently makes the consumer remember the coupling rather than +helping them preserve it. + +## R766-E03 — the common bootstrap still feels more ceremonial than it should + +> ✅ **Decision (2026-07-05): no work to be done.** The webview bootstrap is +> already a single shared `render(key, vscodeApi)` entrypoint +> ([index.tsx](../../../../src/webviews/index.tsx)) that looks up the component in +> `WebviewRegistry` and wraps it in `WithWebviewContext` once; per-view code adds +> nothing to the bootstrap. Making `WithWebviewContext` default to +> `acquireVsCodeApi()` was considered and rejected: passing `vscodeApi` explicitly +> keeps the singleton `acquireVsCodeApi()` call in the consumer's control (it can +> only be called once per webview) and keeps the provider testable. The residual +> ceremony is minimal and intentional. No change. + +The webview side is already straightforward, but the current bootstrap still asks +the consumer to thread `vscodeApi` through `WithWebviewContext` explicitly and to +keep the `render(viewType, vscodeApi)` entrypoint and the React root in sync. +That is not a blocker, but it is the sort of ceremony that makes a package feel +less “just works” than the README promises. + +**Options** + +- **A — provide a small helper** such as `bootstrapWebviewApp({ registry, +getVscodeApi })` or an overload of `WithWebviewContext` that defaults to + `acquireVsCodeApi()` when no prop is provided. +- **B — keep the explicit API and document the common bootstrap pattern more + fully.** +- **C — leave it as-is and rely on the starter kit.** + +**Recommendation:** Option A or B depending on how much the package wants to own +the webview bootstrap. The current API is flexible, but the happy path still has +a little more glue than a new consumer would expect. + +## Decisions summary + +| ID | Severity | Decision (2026-07-05) | Action | +| -------- | -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| R766-E01 | Medium | **Option B** — document the pattern; do **not** add a helper to the library | Document the `openAppWebview`-style integration-preset pattern in ADVANCED.md; no package code | +| R766-E02 | Low | **Non-issue** — already enforced at compile time by `WebviewRegistry` | No action (covered by the E01 registry-pattern documentation) | +| R766-E03 | Low | **No work** — the shared `render(...)` bootstrap is already minimal | No change | + +The common thread: the DocumentDB consumer already implements every ergonomics +win E01–E03 reach for (`openAppWebview` as the integration preset, `WebviewRegistry` +as the typed `viewType` source of truth, a single `render` bootstrap), so the +correct move is to **document those consumer-side patterns**, not to grow the +transport package's API surface. This keeps the package lean and is consistent +with the R766-N06 decision to keep panel-registry state in consumer space. + +## Iteration 5 — done + +The review has a second pass focused on adoption rather than correctness, and all +three findings are decided: + +- **R766-E01 — Option B (docs only).** The `openAppWebview` integration-preset + pattern is the reference implementation; document it in ADVANCED.md rather than + shipping a `createWebviewApp` helper. The only sharp edge on this path (the + `trpc`/`router` mismatch) was already removed in R766-N02. +- **R766-E02 — non-issue.** `WebviewRegistry` + the `WebviewName` union already + make a drifted `viewType` a compile error in the reference consumer. +- **R766-E03 — no work.** The shared `render(key, vscodeApi)` bootstrap is already + minimal; the explicit `vscodeApi` hand-off is intentional. + +Net: **one documentation action** (the E01 integration-preset pattern in +ADVANCED.md, which also covers E02's registry pattern) and **no new package API**. + +## Iteration 6 — change protocol (2026-07-05) + +> This iteration fixes a **runtime regression** found while dogfooding the +> migrated extension: opening a webview (e.g. the collection view) rendered the +> panel chrome but never loaded the web app. The extension host reported the RPC +> command succeeding, but the webview devtools showed +> `localhost:18080/index.js` returning **404**. + +**Root cause (R766-07).** The redesigned `WebviewController` chose the +bundled-vs-dev source layout from `extensionContext.extensionMode === Production`. +The old package chose it from a dedicated `isBundled` option that the consumer +fed from `ext.isBundle` (the webpack `IS_BUNDLE` define). Those are different +axes: the normal F5 dev flow runs a **webpack bundle** (`IS_BUNDLE=true`) in +**Development** mode with `DEVSERVER=true`. The webpack dev server (`webpack +serve`) emits the *bundled* asset name (`views.js`, from `entry: { views }`), so +keying the layout off the mode picked the `dev` (tsc) file `index.js` and +requested `http://localhost:18080/index.js`, which the dev server does not serve +(confirmed: `/views.js` → 200, `/index.js` → 404). The result was a blank +webview with no extension-host output. + +**Post-change validation (all green):** `prettier` (no drift) · `eslint --quiet` +(clean) · `jest` (full suite, 2664 passed / 159 suites) · `tsc` build across all +workspaces (clean). No user-facing strings changed, so `l10n` was skipped. + +| ID | Commit | What changed | Why (motivation) | +| -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R766-07 | `2c2e4f30` | `WebviewController` selects the source layout from a **required** `isBundled` option (restored from the old package); `extensionMode` stays CSP-only. The DocumentDB consumer passes `isBundled: !!ext.isBundle`. Docs + 3 regression tests added. | Keying the layout off `extensionMode` 404'd the dev-server script in the standard bundled-development flow. Bundle-ness (which asset name) and production-ness (CSP hardening) are independent; conflating them broke local dev. | + +## Iteration 7 — reference-implementation tidy-up (2026-07-05) + +> Iteration 5 declared the `_integration` folder the **reference implementation** +> for adopting the package. Iteration 7 acts on that: the folder had grown to a +> flat list where the newer observability adapters were easy to miss and the +> folder README had fallen behind. This is documentation-and-layout only; no +> package API and no runtime behavior change. + +**Findings and actions:** + +- **R766-I01 — group the observability sinks.** The flat folder mixed the + router/transport wiring (`configuration`, `trpc`, `appRouter`, + `openAppWebview`, `WebviewRegistry`, `useTrpcClient`) with two cross-cutting + observability adapters (`rpcConcurrencyLogger`, host; `reportObserverError`, + webview) and their tests. Moved the two adapters and their tests into a new + `observability/` subfolder (via `git mv`, history preserved) and fixed the + relative imports and the two external importers (`openAppWebview.ts`, + `src/webviews/index.tsx`). +- **R766-I02 — README lagged the surface.** The `_integration/README.md` file + table pre-dated the observability adapters and never listed them. Added the + `observability/` row to the file table and two rows to the "When you want to X" + map, refreshed the closing note, and added a dedicated + `observability/README.md` describing each file, its side (host/webview), and + where it is wired. +- **R766-I03 — comments.** Confirmed the moved files keep their thorough + header comments (the R766-S04 / R766-N05 rationale) and that the reorg did not + strand any doc cross-reference. + +**Post-change validation (all green):** `prettier` · `eslint --quiet` · `jest` +(full suite) · `tsc` build. No user-facing strings, so `l10n` skipped. + +| ID | What changed | Why (motivation) | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| R766-I01 | New `_integration/observability/` subfolder holds `rpcConcurrencyLogger` + `reportObserverError` (and tests); imports/importers updated | A flat folder buried the two cross-cutting sinks among the router/transport wiring; grouping them makes the reference layout easier to scan. | +| R766-I02 | `_integration/README.md` now lists every file including the observability subfolder; added `observability/README.md` | The folder README is the on-ramp for adopters and coding agents; it must describe what each file does, and the newer sinks were missing. | + +--- + +# Iteration 8 — latency & load-time analysis (2026-07-06) + +> This iteration is an **analysis pass**, not a code change. The question posed: +> does this PR introduce any latency or load-time degradation in webview +> rendering and message processing — especially the time to open a webview — +> through new loops, extra steps, or lookup abstractions that individually look +> cheap but add up? Findings below; no files were modified in this iteration. + +## Method + +Traced the full webview lifecycle end to end and diffed each stage against +`main`: + +- **Open/load path:** panel creation → `getDocumentTemplate` HTML → + webview boot script → React mount → client bootstrap. +- **Message path:** webview `client.*` call → link chain → `postMessage` → + host dispatch pump (`attachTrpc`) → procedure → response → per-op logging. + +Compared against the pre-PR implementations: `WebviewController.ts` and +`useTrpcClient.ts` from `packages/vscode-ext-react-webview` (both removed by this +PR). + +## Findings + +### Load time (opening a webview) — neutral to slightly *better* + +- **R766-P01 (improvement) — client is now a per-webview singleton.** The old + `useTrpcClient` built a **separate** tRPC client per React component (`useMemo` + per component, each with its own link chain and its own per-operation `window` + `message` listeners). The new `getWebviewConnection` + (`react/connection.ts`) memoizes one `{ client, events }` per `vscodeApi` in a + `WeakMap`, so every component shares one client. Fewer clients, fewer link + chains, fewer listeners established at mount. Net reduction in load-time setup. +- **R766-P02 (improvement) — `loggerLink` dropped from the default chain.** The + old client unconditionally prepended tRPC's `loggerLink()` (per-op console + logging + an extra observable wrapper on every call). The new `connectTrpc` + makes it opt-in (`options.logger`) and substitutes the lighter `eventLink`. +- **R766-P03 (info, one-time) — inert JSON block adds a bounded boot cost.** + `getDocumentTemplate` → `serializeInertJson` makes 3 linear regex passes over + the serialized `{ config + l10n bundle + viewType }`, and the boot script does + one extra `JSON.parse` of that wrapper (R766-N03 hardening). The l10n bundle + can be tens of KB, but this is sub-millisecond and runs **once per open**. Not + a concern. +- **R766-P04 (info) — controllers refactored class→factory with no added work.** + `openCollectionWebview` / `openDocumentWebview` do the same settings reads and + config assembly as the former `WebviewControllerBase` subclasses. + +The panel-creation core (`createWebviewPanel` → set `html` → attach one message +listener) is unchanged in cost. + +### Message processing — one genuine new per-RPC cost (host side) + +- **R766-P05 (watch) — new host-side dispatch logger fires on every completed + op.** The old `WebviewController.setupTrpc` had **no** per-operation logging. + The new `attachTrpc` calls `logProcedure` on every completed query / mutation / + subscription, and in production that logger is `rpcConcurrencyLogger`, which per + op does: + 1. `consoleProcedureLogger.log(entry)` → a **`console.log` on every completed + RPC** on the extension host (string-format cost + console noise); + 2. `callWithAccumulatingTelemetry(...)` → and although the telemetry **emit** + is batched (20 calls / 30 s), `callWithAccumulatingTelemetry` invokes + `callWithTelemetryAndErrorHandling` **on every call** — allocating an + `IActionContext`, an async wrapper, error-handling setup, and `Object.entries` + loops over measurements / properties / distributions. Batching suppresses the + _emit_, not the wrapper machinery. + + Because procedures declared with `publicProcedureWithTelemetry` already run + `callWithTelemetryAndErrorHandling` once via `telemetryMiddlewareBody`, a tracked + RPC now pays **two** `callWithTelemetryAndErrorHandling` invocations per call + (procedure middleware + dispatch logger) — roughly doubling the telemetry-wrapper + overhead per RPC. + + **Mitigating facts:** the logger runs **after** `safePostMessage` posts the + result (so it does not delay the response), and it is `void`-ed + (fire-and-forget, does not block the handler's return). But its synchronous + portion still runs on the host event loop per RPC, so under high-frequency RPC + (grid paging, rapid scroll, many parallel queries) it competes with subsequent + message processing. This is exactly the "individually cheap, adds up" case. + +### Confirmed NOT regressions + +- **R766-P06 — caller factory per operation.** `callerFactory(router)(opContext)` + in `attachTrpc` existed identically in the old `WebviewController`. Not new. +- **R766-P07 — O(N) `window` listener fan-out per message** on the webview client + (`vscodeLink`) is a **pre-existing** design, not introduced here. The R766-S04 + concurrency gauge exists precisely to decide (on evidence) whether it ever needs + a single-listener multiplexer. +- **R766-P08 — structural transport guards** (`isTransportRequestMessage` / + `isTransportResponseMessage`) are new but O(1) per message. Trivial. +- **R766-P09 — `eventLink` always in the chain** replaces the previously + always-on `loggerLink`; net neutral (and `eventLink` is lighter). + +## Recommendation + +The **load path is not degraded** — no new loops or heavy lookups when a webview +opens, and two changes (R766-P01, R766-P02) make it slightly cheaper. The single +recurring new cost is the **per-RPC dispatch logger** (R766-P05), which matters +only under high message volume. Two follow-ups worth considering (see the +in-chat discussion accompanying this iteration for full option analysis): + +1. **Gate the per-op `console.log`** so it is dev/`ExtensionMode`-only; the + concurrency telemetry is the real goal and does not need the console line in + production. +2. **Cheaper concurrency sampling** that avoids running the full + `callWithTelemetryAndErrorHandling` wrapper on every op (e.g. sample 1/N, or + accumulate into a plain module-level counter and only enter the telemetry + wrapper on flush) — a small redesign of `callWithAccumulatingTelemetry`'s + per-call path. + +### Decisions (2026-07-06) + +- **Follow-up 2 — Option 1 selected, implemented in Iteration 10.** The chosen + direction was to split the helper's cheap _accumulate_ path (pure in-memory + arithmetic, no `IActionContext`, no `await`) from the heavy _flush_ path (the + single `callWithTelemetryAndErrorHandling` that emits the rolled-up `dist_*` + fields), migrating existing callers in the same change. This is the only option + that makes the helper's per-call path match its "accumulating" promise, fixes the + cost for every consumer, and keeps the concurrency gauge accurate (no sampling + bias). Shipped as R766-P05b — see the [Iteration 10](#iteration-10--accumulating-telemetry-per-call-cost-2026-07-06) change protocol. +- **Follow-up 1 — resolved, implemented in Iteration 9.** Decided to gate the + per-op console line on `extensionMode !== Production` inside the DocumentDB + adapter (`rpcConcurrencyLogger`), leaving the framework's `consoleProcedureLogger` + untouched as the neutral, opt-in sink. The rejected in-chat alternative + (make `rpcConcurrencyLogger` a pure telemetry sink and rely purely on the + opt-in `ProcedureLogger` seam for console output) was cleaner in the abstract, + but the console line is a DocumentDB area of concern: keeping it automatic in + dev/F5 (no manual wiring while debugging) and dead-code on a shipped build is + the pragmatic fit. See the [Iteration 9](#iteration-9--console-gate-r766-p05-part-1-2026-07-06) change protocol. + +| ID | Finding | Severity | Disposition | +| -------- | -------------------------------------------------------------------------- | ------------ | ----------------------------------------------- | +| R766-P01 | Per-webview singleton client (was per-component) | Improvement | Shipped by PR; no action | +| R766-P02 | `loggerLink` now opt-in (was always-on) | Improvement | Shipped by PR; no action | +| R766-P03 | Inert JSON block: 3 regex passes + 1 `JSON.parse` at boot | Info | One-time, sub-ms; no action | +| R766-P04 | Controllers class→factory | Info | No cost change; no action | +| R766-P05 | Per-op host dispatch logger (`console.log` + `callWithAccumulatingTelemetry`) | Watch | Part 1 (console) ✅ Iteration 9; Part 2 (accumulate/flush) ✅ Iteration 10 | +| R766-P06 | Caller factory per op | Not a regression | Pre-existing; no action | +| R766-P07 | O(N) `window` listener fan-out per message | Not a regression | Pre-existing; measured via R766-S04 | +| R766-P08 | New structural transport guards | Not a regression | O(1) per message; no action | +| R766-P09 | `eventLink` always-on (replaces always-on `loggerLink`) | Not a regression | Net neutral; no action | + +--- + +# Iteration 9 — console gate (R766-P05 part 1) (2026-07-06) + +> Implements the first half of the R766-P05 follow-up from Iteration 8: stop the +> per-op `console.log` on the extension host from executing on shipped, installed +> builds, while keeping it automatic when debugging. The concurrency **telemetry** +> gauge is untouched and still records in every mode (production included). The +> heavier per-op telemetry-wrapper cost (part 2) is deferred to Iteration 10. + +## What changed + +- **R766-P05a — gate the console line on `extensionMode`.** In + `rpcConcurrencyLogger.log`, the delegation to the framework's + `consoleProcedureLogger.log(entry)` is now wrapped in + `if (ext.context.extensionMode !== vscode.ExtensionMode.Production)`. Non-production + modes (`Development` from F5 / "Run Extension", and `Test`) still emit the line; + a packaged extension in `Production` never executes it (no string formatting, no + host-console I/O). The mode is read **per call** — `ext.context` is always + populated by the time an RPC fires (the webview is open ⇒ the extension has + activated), and the comparison is O(1), so no module-load ordering risk and no + measurable added cost. +- **Framework untouched.** `consoleProcedureLogger` in + `@microsoft/vscode-ext-webview` stays the neutral, always-available opt-in sink. + The gating decision lives entirely in the DocumentDB adapter, preserving the + package/consumer boundary. +- **Tests.** `rpcConcurrencyLogger.test.ts` now stubs `ext.context.extensionMode` + per case: existing "delegates to console + records gauge" and "no telemetry + without a concurrent count" cases run under `Development`; added a `Test`-mode + case (console still logs) and a **`Production`** case asserting the console line + is **not** emitted while the concurrency gauge **is** still recorded. + +## Why this shape + +The console line is a DocumentDB area of concern (the adapter chose to include it), +so DocumentDB owns when it fires. Tying it to the local/production mode means the +maintainer keeps zero-friction console visibility while debugging and shipped users +never pay for it — matching how telemetry is already an opt-in, separate path. + +## Post-change validation (all green) + +`jest` (rpcConcurrencyLogger suite, 4/4) · `eslint` (both files, clean) · +`tsc`/type-check (no errors) · `prettier` (unchanged — already formatted). No +user-facing strings, so `l10n` not required. + +| ID | What changed | Why (motivation) | +| --------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| R766-P05a | `rpcConcurrencyLogger` gates `consoleProcedureLogger.log` behind `extensionMode !== Production`; tests cover both paths | The per-op host `console.log` is useful when debugging but dead weight on a shipped build; telemetry gauge stays on everywhere. | + +--- + +# Iteration 10 — accumulating-telemetry per-call cost (2026-07-06) + +> Implements the second half of the R766-P05 follow-up (Follow-up 2, Option 1 +> selected in Iteration 8): the `callWithAccumulatingTelemetry` accumulate/flush +> split, so the per-call path is cheap in-memory work and the Azure telemetry +> pipeline is entered only on flush. This is the cost that ran in production (the +> concurrency gauge is meant to keep recording), so unlike the Iteration 9 console +> gate it is a real per-op saving on shipped builds. + +## Problem restated + +`callWithAccumulatingTelemetry` batched the telemetry **emit** (default 20 calls / +30 s) but still ran the full `callWithTelemetryAndErrorHandling` wrapper — +`IActionContext` allocation, error-handling wiring, `performance.now()`, three +`Object.entries` copy loops, an `await` — on **every** call. For a per-RPC caller +like `rpcConcurrencyLogger` (and every other high-frequency consumer) that per-call +machinery was the cost that adds up. + +## What changed + +- **R766-P05b — accumulate (cheap) / flush (heavy) split.** The per-call path now + runs the populator synchronously against a plain `TelemetrySample` bag and folds + the values into module-level batch totals. **No** `IActionContext`, **no** + `await`, **no** `callWithTelemetryAndErrorHandling` on the happy path. The Azure + pipeline is entered exactly once, on flush, with the rolled-up event. +- **Callback contract changed** from `(context: IActionContext) => T | PromiseLike<T>` + (returning the value, `Promise<T | undefined>`) to + `(sample: TelemetrySample) => void` (synchronous, `void` return). `TelemetrySample` + is a plain `{ measurements; properties; distributions }` bag — this also retires + the `TelemetryWithDistributions` cast (`distributions` is now a first-class field, + no `ctx.telemetry as …`). Every existing call site already `void`-ed the result + and used a synchronous callback, so no caller relied on the promise or the return + value. +- **Populator-throw semantics preserved.** "Errors never accumulate": if the + populator throws, the (partial) sample is discarded and the error is reported once + through `callWithTelemetryAndErrorHandling` under the same `callbackId` — the only + path that still pays the heavy wrapper cost, and it is rare. +- **Defensive finite guard added.** `accumulate` now skips non-finite numbers so a + stray `NaN` / `Infinity` from a caller cannot poison a sum or a min/max reduction + (the old code filtered on capture; the new bag is written directly, so the guard + moved into `accumulate`). +- **Unchanged behavior:** auto-duration distribution (`auto_duration_ms`, now the + populator's synchronous duration), `dist_*_min/max/sum/count` rollup keys, + batch-size / `minFlushIntervalMs` throttle, `flushAccumulatingTelemetry`, flushed + event name equals `callbackId`. + +## Callers migrated (all direct `callWithAccumulatingTelemetry` sites + shorthand) + +- `src/utils/callWithAccumulatingTelemetry.ts` — `meterSilentCatch` shorthand. +- `src/documentdb/ClustersExtension.ts` — `completion.accepted`. +- `src/documentdb/shell/DocumentDBShellPty.ts` — six completion / closing-bracket + trackers. +- `src/webviews/documentdb/collectionView/collectionViewRouter.ts` — `completion.accepted.cv`. +- `src/webviews/_integration/observability/rpcConcurrencyLogger.ts` — the + concurrency gauge (also drops the `TelemetryWithDistributions` cast). +- `meterSilentCatch` callers (schema store, mongo connection strings, playground + evaluator, tree items, etc.) needed **no** change — they use the shorthand, whose + signature is unchanged. + +## Tests + +- `callWithAccumulatingTelemetry.test.ts` — rewritten for the synchronous + sample-bag API; added a **finite-guard** case and a **"per-call path never enters + the telemetry pipeline, only flush does"** case (the redesign's core guarantee), + and the error case now also asserts the throw is reported exactly once under the + event id. +- `rpcConcurrencyLogger.test.ts` — the captured callback is now invoked with a + sample bag directly instead of a `{ telemetry }` context stub. + +## Docs / skills + +- All doc comments in `callWithAccumulatingTelemetry.ts` updated to the sample-bag + model (module type doc, `AUTO_DURATION_DISTRIBUTION_KEY`, the main function's + "cheap per call, heavy only on flush" contract, `accumulate`). +- The `telemetry-instrumentation` skill was reviewed: it documents + `callWithTelemetryAndErrorHandling` and `publicProcedureWithTelemetry` but does + **not** describe `callWithAccumulatingTelemetry`'s callback shape, so no skill + edit was required. (If a future task adds an accumulating-telemetry section to the + skill, it should show the `(sample) => { sample.measurements.x = 1 }` form.) + +## Post-change validation (all green) + +`jest` (full suite, 2668/2668) · `eslint` (touched files, 0 errors; 2 pre-existing +unrelated `require-await` warnings in `DocumentDBShellPty.ts` at lines 226/462) · +`tsc` (project-wide `--noEmit`, clean) · `npm run build` (all packages + extension) +· `prettier` (unchanged — already formatted). No user-facing strings, so `l10n` not +required. + +| ID | What changed | Why (motivation) | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| R766-P05b | `callWithAccumulatingTelemetry` split into cheap synchronous accumulate + heavy flush; callback takes a `TelemetrySample` bag (was `IActionContext`); all callers migrated | The per-call `callWithTelemetryAndErrorHandling` wrapper ran on every call and added up on hot paths (per-RPC); now it runs once per flush. | + +--- + +# Iteration 11 — accumulating-telemetry rename (R766-P06) (2026-07-06) + +> Naming follow-up to Iteration 10. After the accumulate/flush split, the helper's +> `callWith…` name was actively misleading: the `callWith…` convention in +> `@microsoft/vscode-azext-utils` promises "run my work inside a managed scope that +> hands me a full `IActionContext` (telemetry **+** errorHandling **+** ui **+** +> valuesToMask) and auto-records duration/result/errors for it." The redesigned +> helper does none of that on the per-call path — it just fills a plain sample bag, +> synchronously, and returns `void`. This iteration renames it so the name stops +> promising a scoped call. Behavior is unchanged; this is a rename + file move only. + +## What changed + +- **Symbol rename** (language-server-safe, all call sites updated): + - `callWithAccumulatingTelemetry` → **`accumulateTelemetry`** — verb `accumulate` + (not `call`) signals "record a data point into a batch," and drops the false + "your work runs in a scope" cue. + - `flushAccumulatingTelemetry` → **`flushAccumulatedTelemetry`** (reads as "flush + what was accumulated"). + - `AccumulatingTelemetryOptions` → **`AccumulateTelemetryOptions`**. + - Unchanged: `TelemetrySample`, `AUTO_DURATION_DISTRIBUTION_KEY`, `meterSilentCatch` + (its `meter` verb is already correct for a pure counter shorthand). +- **File rename** (via `git mv`, history preserved): + `src/utils/callWithAccumulatingTelemetry.ts` → `src/utils/accumulatingTelemetry.ts` + (+ its `.test.ts`), so the filename no longer names a function that no longer + exists. Updated the import path in **all** importers, including the ~7 + `meterSilentCatch`-only consumers and the two `jest.mock('…')` paths. +- **Callers migrated:** `ClustersExtension`, `DocumentDBShellPty` (×6), + `collectionViewRouter`, `rpcConcurrencyLogger` (call sites → `accumulateTelemetry`); + `extension.ts` (deactivation flush → `flushAccumulatedTelemetry`); `configuration.ts` + and `rpcConcurrencyLogger.ts` doc-comment references. `meterSilentCatch` consumers + needed only the import-path change (the shorthand name is unchanged). +- **Docs:** all `{@link}` / prose references in the helper updated; the stale + Iteration-2 file link in this review doc repointed to the renamed path (older + change-protocol *bodies* are left intact as an accurate record of the name in + force at the time). + +## Why this name (the teachable distinction) + +The verb now encodes **whether your callback is executed**: + +- `accumulateTelemetry(id, (sample) => …)` — your callback only *fills a sample*; no + work is run in a scope, no `ctx`. +- a future `runWithAccumulatingTelemetry(id, (sample) => action)` — genuinely *runs* + your action, so a `runWith…` name would be honest there (tracked as R766-P07 / + GitHub enhancement issue, not built in this iteration). + +Naming the `sample` parameter (instead of `ctx`) reinforces it at every call site: +`sample.measurements` visibly lacks `sample.ui` / `sample.errorHandling`. + +## Telemetry skill + +Reviewed `.github/skills/telemetry-instrumentation`: it documents +`callWithTelemetryAndErrorHandling` and `publicProcedureWithTelemetry` but does +**not** mention the accumulating helper by any name, so no skill edit was required. +(If a future task adds an accumulating-telemetry section, it should show the +`accumulateTelemetry('event', (sample) => { sample.measurements.x = 1 })` form.) + +## Post-change validation (all green) + +`jest` (full suite, 2668/2668) · `eslint` (touched files, 0 errors; 4 pre-existing +unrelated `require-await` warnings) · `tsc` (project-wide `--noEmit`, clean) · +`npm run build` · `prettier`. No user-facing strings, so `l10n` not required. + +| ID | What changed | Why (motivation) | +| -------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| R766-P06 | Renamed `callWithAccumulatingTelemetry`→`accumulateTelemetry`, `flushAccumulatingTelemetry`→`flushAccumulatedTelemetry`, `AccumulatingTelemetryOptions`→`AccumulateTelemetryOptions`; file → `accumulatingTelemetry.ts`; all callers migrated | The `callWith…` name promised a full-context scoped call the redesigned helper no longer provides; the new names match what it actually does. | + +## Deferred — `runWithAccumulatingTelemetry` (R766-P07, tracked as a GitHub issue) + +The "wrap an action" variant (runs the action, batches successes, emits failures +immediately, rethrows) is **not** built here. It was specced and filed as +[microsoft/vscode-documentdb#777](https://github.com/microsoft/vscode-documentdb/issues/777) +(`enhancement`, `[telemetry]` in the title) so it can be picked up when demand for +richer hot-path telemetry arises. Key design points captured in the issue: success +→ accumulate; error/cancel → immediate full emit; rethrow to keep the wrapper +transparent; reuse the Iteration 10 internals. diff --git a/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-rpc-package-decoupling-design.md b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-rpc-package-decoupling-design.md new file mode 100644 index 000000000..d50982662 --- /dev/null +++ b/docs/ai-and-plans/PRs/766-webview-ext-package-redesign/webview-rpc-package-decoupling-design.md @@ -0,0 +1,1242 @@ +# Making `@microsoft/vscode-ext-react-webview` flexible enough for vscode-cosmosdb to adopt + +**Status:** design proposal (no code) · **Date:** 2026-06-24 +**Author context:** comparison of our preview package against the partner +[vscode-cosmosdb](https://github.com/microsoft/vscode-cosmosdb) extension's +`@cosmosdb/webview-rpc` package, extracted in +[PR #3121](https://github.com/microsoft/vscode-cosmosdb/pull/3121) and now on +their `main`. + +> **Addendum (2026-06-24):** §11 revises this whole proposal for the case where +> we have **full freedom to make breaking changes** (preview, no external +> dependents, we are the only consumer, and we are willing to author the +> Cosmos migration PR ourselves). It also answers the build-tooling question +> (do we need Vite first?) and gives an order-of-magnitude **LOC complexity** +> estimate for every change. Where §11 conflicts with §7–§10, §11 wins. +> +> **§12 (2026-06-24)** reframes the whole proposal around the _primary +> audience_ — anyone who just wants a webview — now that we may also open PRs +> on Cosmos's repo. It evaluates Cosmos's choices **on merit** (not to ease +> their migration), proposes subpath **renames**, and defines the +> "best-experience" happy path. Where §12 conflicts with earlier sections, +> **§12 wins** — it supersedes §11's "converge to their names" stance. +> +> **§13 is the consolidated decisions summary (final, locked state).** If you +> read one section, read that one — it supersedes naming and recommendations +> elsewhere in this document. + +--- + +## 1. Executive summary + +We ship one **batteries-included, panel-owning framework** packaged as +`@microsoft/vscode-ext-react-webview`. Cosmos DB forked our transport before our +extraction, then evolved it into a **small, un-opinionated transport toolkit** +(`@cosmosdb/webview-rpc`) that owns _nothing_ about the panel, the tRPC +instance, or the telemetry backend. + +The divergence is not accidental and is not a quality gap on either side. It is +driven by one structural fact: **Cosmos already had a large webview +infrastructure (panel/tab classes, command dispatch, a telemetry backend) built +on a legacy `postMessage` channel protocol.** They could not adopt a base class +that _owns_ the panel lifecycle, so they extracted only the part they needed — +the tRPC message pump — and made every other concern a seam. + +The good news: their design and ours are not in conflict. Theirs is essentially +**our lower layer, exposed directly.** If we factor our `WebviewController` into +a thin _panel-runtime_ layer sitting on top of a free-standing _transport core_ +(plus a couple of additive seams for telemetry, framework-agnostic client use, +and consumer-owned tRPC instances), then: + +- the **normal case stays a one-liner** (extend `WebviewController`, define a + router, call `useTrpcClient`); and +- a **migrating consumer like vscode-cosmosdb can adopt our npm package** in + place of their in-tree copy, because the seams they need are now public. + +Eight recommendations (R1–R8) follow in §7. The headline three are: **R1** extract +a free `setupTrpc(panel, …)` function, **R3** ship instance-agnostic telemetry +middleware _bodies_ + adapter interfaces, and **R5** let the consumer own the +tRPC instance. + +--- + +## 2. The two packages at a glance + +| Dimension | **Ours** `@microsoft/vscode-ext-react-webview` | **Theirs** `@cosmosdb/webview-rpc` | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Distribution | Published to npm (`0.8.0-preview`), ships built `dist/` + `.d.ts` | `private` workspace package, ships **TS source** (`main: src/index.ts`), consumed via npm-workspaces + Vite/tsconfig path aliases, no build step | +| Entry points | **2**: `.` (client **and** React), `./server` (host) | **4**: `.` (shared = `TypedEventSink`), `./server`, `./client` (React-free), `./react` | +| Panel ownership | `WebviewController` **owns** the panel: `createWebviewPanel`, HTML, CSP, config serialization, dev-server, source layout | `setupTrpc(panel, …)` is a free function that **attaches** to a panel the consumer already created; returns a `Disposable` | +| tRPC instance | **Package owns** `initTRPC.create()` — single, context-less; procedures cast `ctx as RouterContext` | **Consumer owns** `initTRPC.context<T>().create()` — one per panel type; passes `createCallerFactory` into `setupTrpc` (no cast) | +| React coupling | `react` is a **hard** peer; the default entry exports the React hooks | `./client` is **React-free**; `./react` is the only entry that imports React; `react` is an **optional** peer | +| Telemetry | `createMiddleware` (bound to the package tRPC instance) + pre-baked `publicProcedureWithTelemetry` + `console` default; `TelemetryContext = { properties, measurements }` | Instance-agnostic `loggingMiddlewareBody` / `telemetryMiddlewareBody` _body factories_ + `ProcedureLogger` / `TelemetryRunner` adapter interfaces; `console` default; **no** tRPC-instance coupling | +| Client error observation | `errorLink(onError)` (errors only) + `useTrpcClient({ onError })` | `createEventChannel()` → `RpcEventChannel` with `onSuccess` / `onError` / `onAborted`, returned as `{ trpcClient, events }` | +| `useTrpcClient` default | **per-component** instance (sharing via context is an "advanced" opt-in) | **per-webview singleton** `{ trpcClient, events }` cached by `vscodeApi` | +| Push events | `TypedEventSink` under `/server` | `TypedEventSink` under `.` (shared) + client-side `createEventChannel` | +| Framework error strings | hard-coded English (`'Procedure not found: …'`) | `@vscode/l10n` (optional peer) — `l10n.t(...)` inside the package | +| `@trpc/*` | peer deps (`^11`) | direct deps, pinned (`~11.18.0`) | + +--- + +## 3. What Cosmos actually built (deep dive) + +### 3.1 `setupTrpc` — a free function, not a class + +The center of gravity. Where we have a `protected setupTrpc()` method inside a +panel-owning class, they have a standalone function: + +```ts +// @cosmosdb/webview-rpc/server +export function setupTrpc<TContext extends BaseRouterContext, TRouter extends AnyRouter>( + panel: vscode.WebviewPanel, + context: TContext, + appRouter: TRouter, + createCallerFactory: (router: TRouter) => (ctx: TContext) => Record<string, unknown>, +): { + disposable: vscode.Disposable; + activeSubscriptions: Map<string, ActiveSubscription>; + activeOperations: Map<string, AbortController>; +}; +``` + +Two consequences: + +1. **The panel is an input, not an output.** Cosmos creates the panel with their + own infrastructure, then calls `setupTrpc(panel, …)` to bolt on the message + pump. They keep their HTML, CSP, config, and dev-server logic. +2. **`createCallerFactory` is injected.** The package does _not_ call + `initTRPC.create()`. The consumer's tRPC instance is the source of truth, so + each panel type can have a precisely typed context with **no `ctx as T` + cast** at procedure sites. + +The body of `setupTrpc` is otherwise _line-for-line our `WebviewController` +dispatch logic_ — same four message branches (`subscription`, +`subscription.stop`, `abort`, default), same `safePostMessage`, same +`toAsyncIterator`, same dual abort-controller + `iterator.return()` subscription +cleanup, same `undefined → null` coalescing. They lifted our hardened transport +verbatim and wrapped it in a function signature that doesn't assume panel +ownership. (They also added a defensive `console.warn` guard for non-tRPC +messages arriving on the same channel — a direct artifact of their legacy +`postMessage` traffic still flowing during migration.) + +### 3.2 Telemetry as instance-agnostic "middleware bodies" + adapters + +This is the cleanest part of their design and the one the user singled out. They +provide middleware **bodies** (plain functions) rather than middleware **bound +to a tRPC instance**: + +```ts +// @cosmosdb/webview-rpc/server +export interface ProcedureInvocation { path: string; type: 'query'|'mutation'|'subscription'; signal?: AbortSignal; } +export interface MiddlewareResultLike { ok: boolean; error?: Error & { cause?: unknown }; } + +export interface ProcedureLogger { + onStart?(info: ProcedureInvocation): void; + onEnd?(info: ProcedureInvocation & { durationMs: number; ok: boolean; aborted: boolean; error?: Error }): void; +} +export interface TelemetryRunner<TEnrichment extends object> { + run<TResult extends MiddlewareResultLike>( + eventId: string, + invocation: ProcedureInvocation, + invoke: (enrichment: TEnrichment) => Promise<TResult>, + ): Promise<TResult>; +} + +export function loggingMiddlewareBody(logger: ProcedureLogger | (() => ProcedureLogger)): /* plain mw fn */; +export function telemetryMiddlewareBody<TEnrichment extends object>( + runner: TelemetryRunner<TEnrichment>, + options?: { buildEventId?: (i: ProcedureInvocation) => string }, +): /* plain mw fn */; +``` + +The consumer wires them into _their own_ tRPC instance: + +```ts +const t = initTRPC.context<MyRouterContext>().create(); +const procedure = t.procedure + .use(t.middleware(loggingMiddlewareBody(myLogger))) + .use( + t.middleware(telemetryMiddlewareBody(myRunner, { buildEventId: ({ type, path }) => `myApp.rpc.${type}.${path}` })), + ); +``` + +Why this matters: the package now knows nothing about Application Insights, +`callWithTelemetryAndErrorHandling`, output channels, or any specific backend. +The `TelemetryRunner` adapter is the seam — the consumer implements `run()` to +open their scope, enrich `ctx`, observe the `MiddlewareResultLike`, and report +success/failure. The framework only chooses the event id (overridable) and +provides the timing/abort plumbing. + +Contrast with ours: `createMiddleware` is `t.middleware` **bound to the package's +single tRPC instance.** If a consumer owns their own instance, our +`createMiddleware` and our `publicProcedureWithTelemetry` are unusable to them — +they cannot be composed onto procedures created from a different `t`. + +### 3.3 `./client` is framework-agnostic; `./react` is the only React importer + +``` +@cosmosdb/webview-rpc/client → vscodeLink, errorLink, createEventChannel, + RpcEventChannel/RpcEventEmitter, wire-protocol types +@cosmosdb/webview-rpc/react → WithWebviewContext, useTrpcClient (imports React) +@cosmosdb/webview-rpc → TypedEventSink (shared) +@cosmosdb/webview-rpc/server → setupTrpc, middleware bodies, BaseRouterContext +``` + +A vanilla (non-React) webview talks to `vscodeLink` directly; React consumers +get the hook. `react` is therefore an _optional_ peer. Our package, by contrast, +puts the React hooks and the framework-agnostic `vscodeLink`/`errorLink`/wire +types in the _same_ default entry, so even a non-React consumer inherits the +`react` peer requirement. + +### 3.4 A richer client-side event channel + +Their `errorLink` publishes into an `RpcEventChannel` rather than calling a bare +callback: + +```ts +export interface RpcEventChannel { + onSuccess(handler: (info: CallInfo, data: unknown) => void): Unsubscribe; + onError(handler: (error: Error, info: CallInfo) => void): Unsubscribe; + onAborted(handler: (info: CallInfo) => void): Unsubscribe; +} +``` + +`useTrpcClient` returns `{ trpcClient, events }` (a per-webview singleton pair +cached by `vscodeApi`), so cross-cutting observers (toasts, ARIA announcements, +telemetry, status bar) subscribe once and see every query/mutation outcome — +with **aborts separated from errors** so a user cancel doesn't surface as an +error toast. It is explicitly observer-only: handlers can't mutate the value or +convert an error to success (they direct you to write a `TRPCLink` for that). + +This is a strict superset of our `errorLink(onError)`. + +### 3.5 Packaging: source, not dist + +Because `@cosmosdb/webview-rpc` is `private` and consumed inside the monorepo via +path aliases, it ships raw TypeScript (`main: src/index.ts`, `typecheck` only, +no build). `@trpc/*` are direct pinned deps; `react` and `@vscode/l10n` are +optional peers. This is the one area where we should _not_ follow them — we +publish to npm and need `dist/` + `.d.ts` + peer-version negotiation. (See R8.) + +--- + +## 4. The "why" — reconstructed rationale + +Mapping each divergence to the constraint that produced it: + +| Their choice | Constraint that forced it | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setupTrpc(panel, …)` free function | They **already own panel/tab/command infrastructure**. A panel-owning base class would mean rewriting all of it. | +| Consumer owns `initTRPC` + passes `createCallerFactory` | They have **multiple panel types** (QueryEditor, Document, Migration) each wanting a **precisely typed context**; one shared package instance forces `ctx as T` casts. | +| Telemetry as adapter interfaces (`TelemetryRunner`) | They have a **concrete, opinionated telemetry backend** (action contexts / App Insights) that must not leak into a reusable package. | +| `./client` React-free + `./react` separate | Their **legacy `postMessage` webviews** and any non-React surface must consume transport without pulling React. | +| Richer `RpcEventChannel` (success/error/abort) | Migrating from a channel protocol, they need **central observability** of every call, and a **first-class "canceled" outcome** for run/cancel flows. | +| `@vscode/l10n` inside the package | A shipping product extension **localizes everything**, including framework error strings. | +| Ships source, no build | It's a **monorepo-internal** package consumed via aliases; npm publishing isn't a goal (yet). | + +The throughline the user named: **their codebase grew up on `postMessage` and +they are migrating incrementally.** Every seam exists so they can adopt tRPC +_without_ discarding the panel/telemetry/command infrastructure they already +have. We started request/response-first and greenfield, so we could afford a +panel-owning convenience class. Both are correct for their respective starting +points. + +--- + +## 5. The core difference, and the layered model that reconciles it + +> **Ours is a _framework_ (an opinionated runtime you extend). Theirs is a +> _toolkit_ (composable functions you assemble).** + +Our `WebviewController` conflates **two genuinely separable concerns**: + +- **(A) the panel runtime** — `createWebviewPanel`, the HTML template, CSP, + config serialization, bundled-vs-dev source selection, dev-server wiring; and +- **(B) the tRPC message pump** — dispatch queries/mutations/subscriptions, + abort + subscription lifecycle, `safePostMessage`. + +Cosmos proved that **(B) is independently valuable and reusable**, and that +**(A) is exactly the part a consumer with existing infrastructure does not +want.** The fix is not to abandon (A) — it is our differentiator and the reason +the starter-kit "normal case" is a one-liner — but to **stop welding (A) and (B) +together** so (B) can be used on its own. + +```mermaid +graph TD + subgraph L3["Layer 3 — Panel runtime (opinionated, OURS only)"] + WC["WebviewController<br/>owns panel · HTML · CSP · config · dev-server"] + end + subgraph L2["Layer 2 — React glue"] + RG["WithWebviewContext · useTrpcClient · useConfiguration"] + end + subgraph L1["Layer 1 — Transport core (what Cosmos extracted)"] + HOST["setupTrpc(panel, ctx, router, createCallerFactory)"] + LINKS["vscodeLink · errorLink · event channel"] + MW["middleware bodies + ProcedureLogger / TelemetryRunner"] + end + subgraph L0["Layer 0 — Shared (side & framework agnostic)"] + WIRE["wire-protocol types"] + SINK["TypedEventSink"] + end + + WC --> HOST + WC --> RG + RG --> LINKS + HOST --> WIRE + LINKS --> WIRE + HOST --> SINK + MW --> WIRE +``` + +- **Normal-case consumer (starter kit):** touches **Layer 3 only** (+ defines a + router). Unchanged from today. +- **Migrating consumer (vscode-cosmosdb):** touches **Layers 0–2**, brings their + own panel (instead of Layer 3), their own tRPC instance, and their own + telemetry adapter. + +This is "progressive disclosure": one easy entry point, with lower layers +available when you outgrow the defaults. + +--- + +## 6. Does it make sense to separate ours? Yes — via subpaths, not multiple packages + +Two ways to expose layers: + +1. **Multiple npm packages** (`…-rpc-core`, `…-rpc-react`, `…-rpc-server`). Cost: + N× versioning, N× release, cross-package peer ranges, and the "diamond + dependency" headache during preview. Benefit: marginal. +2. **One package, multiple subpath exports** (`.`, `./client`, `./react`, + `./server`, optionally `./shared`). Cost: a slightly larger `exports` map. + Benefit: one version, one release, one changelog, and consumers still import + only the layer they need (bundlers tree-shake the rest). + +**Recommendation: stay single-package, add subpaths.** This is precisely what +Cosmos did (one `@cosmosdb/webview-rpc`, four subpaths) and it gives the "RPC +only / no React" sub-API the user asked about without the multi-package tax. + +--- + +## 7. Recommendations + +Each is **additive** and preserves the normal-case one-liner. Effort/risk are +relative (S/M/L). + +### R1 — Extract a free `setupTrpc(panel, …)` core; make `WebviewController` a thin layer on top. **(Effort M · Risk M · highest value)** + +Lift the dispatch logic out of `WebviewController` into a standalone function: + +```ts +// proposed: @microsoft/vscode-ext-react-webview/server +export function setupTrpc<TRouter extends AnyRouter, TContext extends BaseRouterContext>( + panel: vscode.WebviewPanel, + context: TContext, + appRouter: TRouter, + options?: { createCallerFactory?: (router: TRouter) => (ctx: TContext) => Record<string, unknown> }, +): { + disposable: vscode.Disposable; + activeOperations: Map<string, AbortController>; + activeSubscriptions: Map<string, /*…*/ unknown>; +}; +``` + +`WebviewController.setupTrpc()` becomes a one-line call into this, registering +the returned disposable. When `options.createCallerFactory` is omitted, default +to the package's own factory (today's behavior). **This single change is what +unblocks Cosmos adopting our package** — they call `setupTrpc(theirPanel, …)` +against panels they already own. + +### R2 — Add a React-free `./client` subpath; demote React to an optional peer. **(Effort S · Risk S)** + +Introduce `./client` exporting `vscodeLink`, `errorLink` (and the R4 event +channel) and the wire-protocol types — **no React import**. Keep the hooks +(`useTrpcClient`, `useConfiguration`, `WithWebviewContext`) on `.`/`./react`. +Keep `.` re-exporting both so existing imports don't break. Mark `react` +`optional: true` in `peerDependenciesMeta`. Lets a non-React or +different-framework webview use the raw transport. + +### R3 — Ship instance-agnostic middleware _bodies_ + adapter interfaces. **(Effort M · Risk S)** + +Add `loggingMiddlewareBody(logger)` and `telemetryMiddlewareBody(runner, opts)` +returning plain middleware functions (not bound to any tRPC instance), plus the +`ProcedureLogger` / `TelemetryRunner` / `ProcedureInvocation` / +`MiddlewareResultLike` types. **Keep** `publicProcedureWithTelemetry` + the +`console` default as the zero-config path. This is what lets a consumer who owns +their own tRPC instance (R5) still use our telemetry plumbing, and lets Cosmos +drop their App-Insights adapter into our package unchanged. + +### R4 — Generalize `errorLink(onError)` into an optional event channel. **(Effort M · Risk S)** + +Add `createEventChannel()` → `{ onSuccess, onError, onAborted }` and an +`eventLink(channel)` that publishes into it (keep `errorLink` as the +error-only convenience). Let `useTrpcClient` optionally return +`{ trpcClient, events }` when a channel is requested. The simple +`useTrpcClient({ onError })` and bare `{ trpcClient }` cases stay intact. +Separating **aborted** from **errored** is a real ergonomic win for run/cancel +flows. + +### R5 — Allow the consumer to own the tRPC instance. **(Effort M · Risk M)** + +Accept an optional `createCallerFactory` on `WebviewControllerOptions` and on +`setupTrpc` (R1). When provided, dispatch against the consumer's instance — +enabling **per-panel precisely-typed contexts and zero `ctx as T` casts**. When +omitted, fall back to the package instance. This removes the only substantive +reason we previously recorded for _not_ adopting Cosmos's per-panel pattern (the +cast), while keeping our single-instance default for the normal case. + +### R6 — Make framework-internal error strings localizable. **(Effort S · Risk S · optional)** + +Either add `@vscode/l10n` as an optional peer and use `l10n.t(...)` for the two +internal `'Procedure not found'` throws, or accept an optional +`translate?: (key: string, args?: …) => string` in options. Normal case needs no +change. Low priority, cheap, and it matches a shipping-product expectation. + +### R7 — Keep panel-runtime conveniences in `WebviewController` _only_. **(Effort 0 · Risk 0 · a "don't")** + +Do **not** push HTML/CSP/config/dev-server logic down into the transport core. +That layer is our differentiator and the entire value of the "normal case." It +belongs in Layer 3, wrapping the core. Document the two layers explicitly so +nobody re-welds them. + +### R8 — Packaging hygiene for cross-extension consumption. **(Effort S · Risk S)** + +To let an external extension consume _our npm package_ (not a source copy): keep +`@trpc/*` and `react` as peers (no duplicate bundles), keep clean subpath +`exports` with matching `types`/`typesVersions`, and **move `TypedEventSink` + +the wire-protocol types to a shared/root entry** so both host and client import +them from one place (today `TypedEventSink` is `/server`-only; the wire types are +client-only). Do **not** copy their ship-source/no-build model — we publish dist. + +### Recommendation dependency / sequencing + +```mermaid +graph LR + R1["R1 free setupTrpc"] --> R5["R5 consumer-owned tRPC instance"] + R3["R3 middleware bodies"] --> R5 + R2["R2 ./client + optional react"] --> R4["R4 event channel"] + R8["R8 packaging / shared entry"] -.-> R1 + R8 -.-> R2 + R1 --> WC["WebviewController stays a 1-liner (R7)"] +``` + +Suggested order: **R8 → R1 → R3 → R5** (the adoption-critical spine), then +**R2 → R4** (client ergonomics), then **R6** (polish). + +--- + +## 8. Proposed end-state API shape + +### 8.1 Normal case — unchanged (starter kit) + +```ts +// host: appRouter.ts +import { + publicProcedureWithTelemetry as p, + router, + type BaseRouterContext, +} from '@microsoft/vscode-ext-react-webview/server'; +export type RouterContext = BaseRouterContext & { workspaceRoot: string }; +export const appRouter = router({ hello: p.input(/*…*/).query(/*…*/) }); +export type AppRouter = typeof appRouter; + +// host: MyViewController.ts +class MyViewController extends WebviewController<AppRouter, MyConfig, RouterContext> { + /* …super(...)… */ +} + +// webview: MyView.tsx +const { trpcClient } = useTrpcClient<AppRouter>(); +``` + +Nothing here changes. All new surface is opt-in. + +### 8.2 Migrating case — what Cosmos would write against _our_ package + +```ts +// host — they own the panel and the tRPC instance +import { initTRPC } from '@trpc/server'; +import { + setupTrpc, + loggingMiddlewareBody, + telemetryMiddlewareBody, + type BaseRouterContext, +} from '@microsoft/vscode-ext-react-webview/server'; + +interface QueryEditorCtx extends BaseRouterContext { + panel: vscode.WebviewPanel; + db: Db; +} +const t = initTRPC.context<QueryEditorCtx>().create(); +const procedure = t.procedure + .use(t.middleware(loggingMiddlewareBody(theirOutputChannelLogger))) + .use( + t.middleware( + telemetryMiddlewareBody(theirAppInsightsRunner, { + buildEventId: ({ type, path }) => `cosmosDB.rpc.${type}.${path}`, + }), + ), + ); + +export const queryEditorRouter = t.router({ + /* …procedures… */ +}); + +// attach to a panel they already created with their own infra: +const { disposable } = setupTrpc(theirPanel, { panel: theirPanel, db }, queryEditorRouter, { + createCallerFactory: t.createCallerFactory, +}); +theirDisposables.push(disposable); +``` + +```ts +// webview (their vanilla, non-React surface) — uses ./client, no React peer +import { vscodeLink, errorLink, createEventChannel } from '@microsoft/vscode-ext-react-webview/client'; +``` + +Every line above is satisfied by R1–R5. Today, none of it is possible against +our package without forking. + +--- + +## 9. What to deliberately _not_ do + +- **Don't** split into multiple npm packages (use subpaths — §6). +- **Don't** drop `WebviewController` or move its panel/HTML/CSP/config logic into + the core (R7). The "normal case" one-liner is the product. +- **Don't** copy their ship-source/no-build packaging (R8) — we publish to npm. +- **Don't** make the consumer-owned tRPC instance or the event channel mandatory. + Defaults (package instance, `{ trpcClient }`, `console` telemetry) must keep + the greenfield path free of ceremony. +- **Don't** adopt their generic `command({ commandName, params })` dispatch + shortcut — that's a legacy-migration crutch that discards tRPC type safety; we + have no legacy channel to migrate from. + +--- + +## 10. Open questions for the team + +1. **Default entry shape.** Keep `.` = "client + react" (with a new React-free + `./client`), or flip to `.` = shared, `./client`, `./react`, `./server` + (Cosmos's shape) and take the one-time breaking change while still in preview? +2. **`TypedEventSink` home.** Promote to a shared/root entry (R8) — worth the + export-map churn during preview? +3. **Event channel vs `errorLink`.** Ship both (channel as the superset, keep + `errorLink` as the alias), or replace `errorLink` outright before 1.0? +4. **Telemetry context type.** Our `TelemetryContext = { properties, +measurements }` vs their `TelemetryRunner<TEnrichment>` enrichment model. + Adopt the adapter interfaces (R3) _alongside_ our current convenience, or + converge on one? +5. **Coordination with Cosmos.** Would they pilot consuming our package behind a + feature branch once R1/R3/R5 land, to validate the seams before we cut 1.0? + +--- + +## Appendix A — Source references + +**Ours** (`packages/vscode-ext-react-webview/`): + +- [src/extension-server/WebviewController.ts](../../packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts) — panel-owning controller + dispatch +- [src/extension-server/trpc.ts](../../packages/vscode-ext-react-webview/src/extension-server/trpc.ts) — package-owned tRPC instance, `createMiddleware`, telemetry default +- [src/webview-client/vscodeLink.ts](../../packages/vscode-ext-react-webview/src/webview-client/vscodeLink.ts) · [errorLink.ts](../../packages/vscode-ext-react-webview/src/webview-client/errorLink.ts) · [useTrpcClient.ts](../../packages/vscode-ext-react-webview/src/webview-client/useTrpcClient.ts) +- [package.json](../../packages/vscode-ext-react-webview/package.json) — 2 entry points, peer deps + +**Theirs** (`microsoft/vscode-cosmosdb`, branch `main`, via PR #3121): + +- `packages/webview-rpc/package.json` — 4 subpaths, ship-source, `private` +- `packages/webview-rpc/src/server/setupTrpc.ts` — free-function dispatcher +- `packages/webview-rpc/src/server/middleware/{types,loggingMiddleware,telemetryMiddleware}.ts` — adapter pattern +- `packages/webview-rpc/src/client/events.ts` — `RpcEventChannel` / `createEventChannel` +- `packages/webview-rpc/README.md` — wiring guide and subpath-separation rationale + +--- + +## 11. Addendum — revised scope under full freedom to break + +**New inputs (2026-06-24):** + +1. The package is **preview**, has **no external dependents**, and we are + **currently its only consumer**. We may change it **completely** and fix our + own extension code wherever the rework breaks it. +2. After the rework, Cosmos would **attempt to migrate** their extension onto our + package — and we may **author that migration PR for them** to review. +3. Still discussion only; no work yet. + +These remove every backward-compatibility constraint that hedged §7–§9. The +recommendations below replace the cautious "keep `.` re-exporting / keep +`errorLink` as well / additive only" framing with a **clean-slate redesign**, and +add a strategic goal that did not exist before: **minimise _their_ migration +diff**, because we are the ones who will write it. + +### 11.1 The new strategic goal: design for their migration + +Because we will author Cosmos's migration PR, the cost we are really optimising +is **our rework + our consumer update + their migration**, as one budget. The +cheapest total is reached when **their migration becomes a dependency-name swap** +rather than a semantic rewrite. That argues for **deliberate API convergence**: +name and shape our public surface to match what Cosmos already runs in +production, then add our extras on top. + +Concretely, converge on their proven, validated names and signatures: + +- the free function `setupTrpc(panel, ctx, router, { createCallerFactory })`; +- middleware **bodies** `loggingMiddlewareBody` / `telemetryMiddlewareBody` with + the `ProcedureLogger` / `TelemetryRunner` / `ProcedureInvocation` / + `MiddlewareResultLike` adapter types; +- the client event channel `createEventChannel()` → `RpcEventChannel` + (`onSuccess` / `onError` / `onAborted`); +- subpaths `.` (shared), `./client` (React-free), `./react`, `./server`. + +Our package then becomes a **strict superset** of `@cosmosdb/webview-rpc`: +their entire surface, **plus** our Layer-3 `WebviewController` (panel runtime), +**plus** npm publishing with built `dist/`. Their migration is then: change the +import specifier `@cosmosdb/webview-rpc` → `@microsoft/vscode-ext-react-webview`, +delete their in-tree package, done. They keep their own panels and tRPC +instances exactly as today. + +### 11.2 Two ways to get there + +```mermaid +graph TD + subgraph A["Option A — evolve ours independently"] + A1["Invent our own names for the new seams"] --> A2["Cosmos migration = semantic rewrite (adapter renames, signature reconciliation)"] + end + subgraph B["Option B — converge-and-superset (recommended)"] + B1["Adopt Cosmos's validated names/shapes as the baseline"] --> B2["Re-add WebviewController layer + dist publishing on top"] + B2 --> B3["Cosmos migration ≈ change the dependency name"] + end +``` + +**Recommendation: Option B (converge-and-superset).** Cosmos has already +production-validated the lower-layer shape (4 subpaths, free `setupTrpc`, +middleware bodies, event channel). Re-deriving our own near-identical surface +would duplicate that validation and tax their migration for no benefit. We add +value _above_ their line (the panel-runtime controller, the zero-config +starter-kit defaults, npm publishing), not by renaming their primitives. + +Net effect on §7: **R1–R6 all promote from "additive, keep old surface" to +"replace outright."** Specifically — drop the `.`-re-export hedge (R2), replace +`errorLink`-only with the event channel as canonical (R4, keep `errorLink` only +as a 3-line convenience over the channel), and replace the +`createMiddleware` / `publicProcedureWithTelemetry` / `TelemetryContext` model +with the adapter-body model (R3), keeping a `consoleProcedureLogger` default +so the starter-kit path stays zero-config. + +### 11.3 The normal case is _still_ a one-liner + +Convergence does not cost the greenfield ergonomics. Layer 3 stays: + +```ts +class MyViewController extends WebviewController<AppRouter, MyConfig, RouterContext> { + /* super(...) */ +} +``` + +The `WebviewController` internally owns one tRPC instance, wires the default +console logger, and calls the new `setupTrpc` for the consumer. A starter-kit +user never sees `createCallerFactory`, middleware bodies, or the event channel +unless they reach for them. The difference from today is purely _internal +plumbing_ plus _new optional seams exposed alongside_. + +### 11.4 Build tooling: do we need to move to Vite first? + +**No. The refactor is a source/API reshape, not a build-system change, and it is +fully decoupled from the bundler choice.** + +Why our current toolchain already supports everything proposed: + +| Concern | Today | Does the rework force a change? | +| -------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Package build | `tsc -p .` → CommonJS `dist/` + `.d.ts` (composite) | **No.** `tsc` compiles all of `src/**` regardless of how many subpath entries exist. Adding `./client` / `./react` / `./server` is just adding entry files + `exports` / `typesVersions` map keys — the **same mechanism** `./server` already uses and which already works. | +| Consumer (webview) bundle | webpack + `swc-loader`, `target: web`, output ESM | **No.** webpack resolves subpath `exports` natively and tree-shakes the React-free `./client` path. Optional `react` peer needs no loader change. | +| Consumer (extension host) bundle | webpack (ext config) | **No.** Resolves `./server` from `dist/` as today. | +| Dev server / HMR | webpack-dev-server on `:18080`, CSP wired by `WebviewController` | **No.** Lives entirely in Layer 3, which we keep. A consumer who brings their own panel (Cosmos) brings their own dev-server story — out of our scope. | +| ESM vs CJS | package emits CJS; Cosmos package is `"type": "module"` | **No.** Our consumers' webpack handles CJS fine. Matching their ESM is _optional_ and independent of the API rework. | + +Cosmos uses Vite **only because** their package is a `private`, build-less +workspace package consumed through path aliases — that is a _consumer-side_ +packaging decision, not a requirement of the API shape. We publish to npm and +ship built `dist/`; our `tsc` → `dist` → webpack-resolves-`dist` pipeline +satisfies every recommendation here. + +**Conclusion:** stay on **webpack + `swc` (bundles)** and **`tsc` (package +build)**. Treat any Vite migration as an **independent, optional DX project** +(faster HMR) that must **not** block or sequence before this rework. The only +build-adjacent edits the rework needs are **package.json `exports` / +`typesVersions` keys and a `tsconfig` include or two** — order ~10 LOC. + +### 11.5 Complexity assessment (order-of-magnitude LOC changed) + +Buckets are powers of ten of _authored lines added/changed/moved_ (deletions +noted separately). Grounded in the real surface: `WebviewController.ts` ≈ 600 +lines (~300 of which is the dispatch pump to extract); the consumer +`_integration/` folder is ~4 files totalling a few hundred lines; ~10 files in +our extension import the package (5 are a one-symbol `useConfiguration` import). + +#### Package rework (`packages/vscode-ext-react-webview/`) + +| Change | LOC bucket | What it touches | +| ------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| R1 — extract free `setupTrpc` core; controller calls it | **~100** | move ~300 dispatch lines into a new `setupTrpc.ts`; controller shrinks to a one-line call | +| R2 — split `./client` (React-free) + `./react`; `react` optional peer | **~100** | relocate hooks/context to a `react/` folder; new entry files; `exports` map | +| R3 — middleware bodies + adapter interfaces (replace `createMiddleware`/`publicProcedureWithTelemetry` model) | **~100** | 3–4 new files (`types`, `loggingMiddleware`, `telemetryMiddleware`) ≈ 250 lines; keep `consoleProcedureLogger` default | +| R4 — event channel (`createEventChannel`/`RpcEventChannel`) replaces `errorLink`-only | **~100** | new `events.ts` ≈ 150 lines + link/hook wiring; `errorLink` becomes a thin shim | +| R5 — consumer-owned tRPC instance (`createCallerFactory` option) | **~10** | thread one optional param + generics through options | +| R6 — localizable framework strings (optional `@vscode/l10n`) | **~10** | 2 throw strings + peer-dep entry | +| R7 — keep `WebviewController` panel runtime (no-op) | **~0** | nothing moves down | +| R8 — move `TypedEventSink` + wire types to a shared `.` entry | **~100** | file moves + `exports` + import rewrites within the package | +| README + Quick-start rewrite | **~100** | docs reflecting the new layered surface | +| Tests (new `setupTrpc`/middleware/event-channel suites, update existing) | **~100** | a handful of Jest suites | +| **Package total** | **~1000** | low thousands of churn, concentrated and mechanical | + +#### Our extension consumer update (`src/webviews/`) + +| Change | LOC bucket | What it touches | +| --------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- | +| Rewrite `_integration/` (own tRPC instance, middleware bodies, new imports) | **~100** | `appRouter.ts` (~190 L), `trpc.ts` (~150 L), `WebviewControllerBase.ts`, `useTrpcClient.ts` | +| Import-path swaps for hooks (`.` → `./react`) in components + entry | **~10** | ~6 files (`CollectionView`, `documentView`, `QueryEditor`, `QueryInsightsTab`, `ToolbarMainView`, `index.tsx`) | +| **Consumer total** | **~100** | hundreds, concentrated in `_integration/` | + +#### Cosmos migration PR (their repo, authored by us) + +| Change | LOC bucket | What it touches | +| ---------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------- | +| Delete their in-tree `@cosmosdb/webview-rpc` package | **~1000 deleted** | ~1.5k lines of source + tests removed | +| Swap import specifier across their webview code | **~100** | mechanical, ~dozens of files (trivial under Option B convergence) | +| Reconcile residual signature/packaging gaps | **~100** | only the deltas our superset does not already match | +| **Their migration total** | **~1000** | dominated by deletion + mechanical swaps; small authored delta under Option B | + +#### Programme total + +| Stream | Authored LOC | Deleted LOC | +| ------------------- | ------------------------- | ----------------------------- | +| Our package rework | **~1000** | — | +| Our consumer update | **~100** | — | +| Cosmos migration | **~100–1000** | **~1000** (their old package) | +| **Grand total** | **~1000** (low thousands) | **~1000** | + +The whole programme is a **~1000-LOC (low-thousands)** effort, not a +~10000-LOC one. It is large in _file count_ and _coordination_, but small in +_novel logic_: nearly every line is either **moving already-hardened code** +(the dispatch pump, the sink, the links) into new files, **adapting types**, or +**rewriting `exports`/imports**. There is little net-new algorithmic code — the +risk is mechanical (broken imports, export-map typos), not architectural. + +### 11.6 Revised recommendations & sequencing + +Under Option B the order optimises for an early, demonstrable "Cosmos could +adopt this" milestone: + +1. **R8 + R2** — lock the subpath layout (`.`, `./client`, `./react`, + `./server`) and the shared entry first; everything else slots into it. (~100) +2. **R1 + R5** — free `setupTrpc` with the consumer-owned-factory option. This + is the adoption-critical spine. (~100) +3. **R3** — middleware bodies + adapters; rewire our `_integration/` telemetry + onto them. (~100 pkg + ~100 consumer) +4. **R4** — event channel; rewire our webview observers. (~100) +5. **R6** — l10n polish. (~10) +6. **Our consumer update** lands continuously with each step (we are the + regression test for the new surface). +7. **Cosmos migration PR** authored last, once the superset is stable. + +### 11.7 Resolved open questions (supersedes §10) + +- **§10 Q1 (default entry shape):** Resolved — adopt the 4-subpath layout + outright (`.` = shared); take the breaking change now while in preview. +- **§10 Q2 (`TypedEventSink` home):** Resolved — promote to the shared `.` + entry (R8). +- **§10 Q3 (event channel vs `errorLink`):** Resolved — event channel is + canonical; `errorLink` survives only as a thin convenience. +- **§10 Q4 (telemetry context type):** Resolved — adopt the adapter-body model; + retire `TelemetryContext`/`publicProcedureWithTelemetry` in favour of + `ProcedureLogger`/`TelemetryRunner` + a console default. +- **§10 Q5 (coordinate with Cosmos):** Still open, now stronger — we intend to + author their migration PR, so a shared checkpoint on naming before R1 lands + is worth it. + +Still genuinely open: whether to also flip the package to ESM (`"type": +"module"`) to match Cosmos byte-for-byte — independent of the API rework, and +not required (§11.4). + +--- + +## 12. Capstone — design the best package, then align both repos to it + +**New inputs (2026-06-24):** + +1. Our freedom extends into **Cosmos's repo too** — we are contributors and may + open PRs there. So we are **not bound to their names/framing**; we can pick + the best shape and bring _both_ codebases to it. +2. The **one hard constraint**: do **not** force them onto our request/response + (controller-owned-panel) model. Their bring-your-own-panel use case must stay + **first-class and possible**. +3. The **north star**: _users who just want a webview should have the best + experience ever, with a simple API._ That audience is primary; the + advanced/embedding audience is first-class but secondary. +4. Renames are on the table when **principled** — "if a change isn't a random + request, they'll take it." Equally: **do not change things for the sake of + changing them.** "This is already the best" is a valid conclusion. + +This supersedes §11's framing. §11 optimised for _minimising their migration_ +(adopt their names). The correct objective is **maximising the quality of the +package for the primary audience**, while keeping the advanced path possible — +and then aligning both repos, including via PRs we author on theirs. + +### 12.1 The real bug to fix: the package is "too bundled" + +The diagnosis from the field is precise: the package _bundles too much_. A +consumer who wanted only the transport could not get it, so they reimplemented +rather than adopt. `WebviewController` welds together panel creation, HTML/CSP, +config serialization, dev-server, tRPC dispatch, tRPC-instance ownership, and +telemetry — **take it all or take nothing.** That is the single defect that made +the package unadoptable for the embedding case. + +The fix is not "add seams beside the monolith." It is a structural rule that +prevents re-bundling from ever recurring: + +> **The self-hosting-facade rule.** The convenience layer must be built +> _entirely on top of the package's own public primitives._ Nothing the +> happy-path facade uses may be reachable **only** through the facade. If +> `WebviewController` dispatches tRPC, it must do so by calling the _public_ +> `setupTrpc`; if it wires telemetry, it uses the _public_ middleware bodies. + +When the facade is just the _first consumer_ of the primitives, "too bundled" +becomes structurally impossible: every capability the simple user enjoys is, by +construction, independently importable by the embedding user. One +implementation, one test surface, two audiences. + +### 12.2 Two audiences, one package (progressive disclosure) + +```mermaid +graph TD + subgraph P["Primary audience — 'I just want a webview'"] + H1["import the facade: open a panel, render a view"] + end + subgraph S["Secondary audience — 'I have my own panel/infra'"] + E1["import primitives: setupTrpc, links, middleware bodies"] + end + H1 -->|"facade is built on"| PRIM["Public primitives (Layer 0/1)"] + E1 --> PRIM + PRIM --> NOTE["Same code path. No private happy-path shortcuts."] +``` + +- **Primary** touches only the facade and writes the least code that can + possibly work. Best experience ever. +- **Secondary** composes the primitives the facade itself uses — a _supported, + first-class_ path, not a downgrade. +- Because the facade has no private capabilities, the primary user can graduate + to the secondary path incrementally **without a rewrite** when one view + outgrows the defaults. + +### 12.3 Evaluate Cosmos's choices on merit (keep vs change) + +We adopt their primitives where they are genuinely the best design, and diverge +**only with a stated reason**. This is the anti-churn discipline the brief asks +for. + +| Cosmos choice | Verdict | Reason | +| ---------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Free `setupTrpc(panel, …)` taking a panel | **Keep** | Correct primitive; it _is_ the embedding use case. The facade calls it (12.1). | +| Consumer-supplied `createCallerFactory` (consumer owns the tRPC instance) | **Keep** | Enables per-panel typed context, kills the `ctx as T` cast. The facade supplies it internally so the simple user never sees it. | +| Middleware **bodies** + `ProcedureLogger` / `TelemetryRunner` adapters | **Keep** | Best-in-class telemetry decoupling; nothing about it harms the simple path (we ship a console default). | +| `react` as an optional peer; React isolated to its own subpath | **Keep** | A webview need not be React. Correct. | +| `createEventChannel` / `RpcEventChannel` (`onSuccess`/`onError`/`onAborted`) | **Keep**, but make it **opt-in** | Superset of our `errorLink`; separating _aborted_ from _errored_ is genuinely better. See 12.5 for the ergonomic tweak. | +| Subpath names `./server` / `./client` | **Change → `./host` / `./webview`** | Principled: "extension host" and "webview" are _the_ VS Code terms; "server" invites confusion with HTTP/language/MCP servers. The primary audience thinks in VS Code vocabulary, not generic tRPC vocabulary. (12.4) | +| `useTrpcClient()` returns the tuple `{ trpcClient, events }` | **Change → client-first** | The 90% case wants only the client; returning a pair taxes every call site. Make `useTrpcClient()` return the client and expose the channel separately/opt-in. (12.5) | +| Ship TS source, `private`, Vite aliases, no build | **Don't adopt** | We publish to npm with built `dist/` (§11.4, R8). | +| One tRPC instance per panel type | **Keep as a capability, not a mandate** | Great for many-panel apps; the facade defaults to a single instance for the simple case. | + +Everything in the **Keep** rows we adopt essentially unchanged — _not_ to ease +their migration, but because it is already the right design. Saying "this is +already best" is the correct answer for those rows. + +### 12.4 Proposed subpath layout (with rename) + +| Subpath | Side | Contents | vs Cosmos | +| ----------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `.` | shared (no `vscode`, no React) | `BaseRouterContext`, wire-protocol types, `TypedEventSink`, an `initWebviewTrpc<TContext>()` helper, re-exported tRPC `router` / `publicProcedure` | richer than their `.` (adds the typed-init helper) | +| `./host` | extension host (Node + `vscode`) | `setupTrpc`, middleware bodies + adapter interfaces, **and the facade** (`WebviewController` / `openWebview`) | **renamed** from `./server` | +| `./webview` | webview (browser, framework-agnostic) | `vscodeLink`, `errorLink`, `createEventChannel`, wire-protocol re-exports | **renamed** from `./client` | +| `./react` | webview (React) | `WithWebviewContext`, `useTrpcClient`, `useConfiguration`; built on `./host`? no — on `./webview` | same name | + +The primary user touches exactly three obvious entries: define a router (`.`), +open a panel (`./host`), call a hook (`./react`). `.` must **never** import +`vscode`, so a webview bundle that imports shared types stays host-free — a +correctness property, not just hygiene. + +> `server`/`client` is defensible (it is tRPC's own vocabulary), so this rename +> is a _recommendation with a reason_, not a hard requirement. The switching +> cost is ~one line per import site in both repos; we would carry it into +> Cosmos via a PR because the VS Code-domain precision helps their readers too. + +### 12.5 The "best experience ever" happy path (illustrative) + +Three things make the simple path excellent; all are facade-level and hide the +primitives: + +**(a) Typed context with no cast.** Offer a thin typed-init helper so the simple +user gets a context-bound tRPC instance without touching `initTRPC` or writing +`ctx as RouterContext`: + +```ts +// '.' (shared) +export const { router, publicProcedure, publicProcedureWithTelemetry, createCallerFactory } = + initWebviewTrpc<MyContext>(); // MyContext extends BaseRouterContext +``` + +This is the same primitive the embedding user can call — or they can bring their +own `initTRPC`. Self-hosting again. + +**(b) A factory front door, not a 6-arg constructor.** Today's controller takes +six positional constructor args. A single options bag with defaults is a +strictly better first experience (the class stays available for views that want +lifecycle hooks or methods): + +```ts +// './host' +openWebview(extensionContext, { + title: 'My View', + viewType: 'myView', + router: appRouter, + context: { + /* MyContext */ + }, + config: { + /* initial webview config */ + }, + // everything below is optional with sane defaults: + // sourceLayout, devServerHost, telemetry (defaults to console), icon, viewColumn +}); +``` + +Internally `openWebview` owns the panel/HTML/CSP/dev-server **and calls the +public `setupTrpc(panel, context, router, { createCallerFactory })`** — the +embedding user's exact primitive. + +**(c) Client-first hook, events opt-in.** Optimise the common case: + +```tsx +// './react' +const trpc = useTrpcClient<AppRouter>(); // 90% case: just the client +const events = useRpcEvents(); // opt-in cross-cutting channel +``` + +Net: the simple user writes a router, one `openWebview` call, and a hook — fully +typed, no casts, console telemetry for free, no knowledge of `setupTrpc`, +`createCallerFactory`, middleware bodies, or links required. + +### 12.6 Strategy: Option C — design-best-then-align-both + +```mermaid +graph LR + D["Design the best package for the primary audience"] --> US["Reshape ours"] + D --> COSMOS["Author PRs on Cosmos to adopt the principled deltas"] + US --> ALIGN["Both repos converge on one best surface"] + COSMOS --> ALIGN + ALIGN --> RESULT["Cosmos keeps its panels/infra; simple users get the great default"] +``` + +This supersedes §11.2's Option B. We are no longer adopting their names to lower +their migration; we are choosing the best names and **bringing them along via +PRs** where our choice is a principled improvement (the subpath rename, the +typed-init helper, the client-first hook). Where their choice is already best +(the primitives in 12.3), nothing changes for them at all. Their migration stays +cheap as a _consequence_ of shared good design, not as the objective. + +The hard constraint is honoured structurally: the **primitive layer imposes no +panel model, no tRPC-instance ownership, and no telemetry backend**, so Cosmos +migrates onto our _primitives_ without adopting our _policies_. They never touch +the facade. + +### 12.7 Deliberately unchanged (anti-churn ledger) + +To be explicit that we are not changing things for their own sake, these stay as +they are today / as Cosmos already has them: + +- the tRPC-over-`postMessage` wire protocol and message shapes; +- the hardened subscription/abort lifecycle (`iterator.return()` + abort signal) + and `safePostMessage`; +- `TypedEventSink`'s single-consumer semantics and API; +- `BaseRouterContext` (`signal?`, `telemetry?`) as the context contract; +- middleware-body + adapter-interface shapes (`ProcedureLogger`, + `TelemetryRunner`, `ProcedureInvocation`, `MiddlewareResultLike`); +- publishing model (npm + built `dist/`, peers for `@trpc/*` and `react`). + +### 12.8 Package name footnote + +With React now optional and the package centred on transport + a panel facade, +the name `@microsoft/vscode-ext-react-webview` slightly over-indexes on React. +It is **not** worth churning unless we are renaming for other reasons — but if a +rename ever happens, `@microsoft/vscode-ext-webview` (React as one binding among +potential others) would describe the post-reshape scope better. Noted, not +recommended on its own. + +### 12.9 Complexity delta vs §11.5 + +The capstone adds three small facade-level pieces on top of §11's estimate: the +`initWebviewTrpc` helper (**~10**), the `openWebview` factory wrapper over the +existing controller (**~10–100**), and the `./server`→`./host` / +`./client`→`./webview` rename (**~10** in our repo, **~10** in Cosmos's, both +mechanical import-specifier edits). None changes the order of magnitude: the +programme remains a **~1000-LOC (low-thousands)** effort dominated by moving +already-hardened code. The rename is the cheapest high-visibility win in the +whole plan. + +### 12.10 Decisions for the team + +1. **Subpath names:** `./host` + `./webview` (recommended, VS Code-precise) vs + `./server` + `./client` (tRPC-familiar). Low cost either way; pick once. +2. **Front door:** factory `openWebview(...)` as the primary API, with the + `WebviewController` class retained for stateful/lifecycle views — or keep the + class as the only front door? +3. **Typed init:** ship `initWebviewTrpc<TContext>()` to eliminate the cast on + the simple path? (Recommended.) +4. **Hook shape:** `useTrpcClient()` returns the client, `useRpcEvents()` for the + channel (recommended) vs Cosmos's `{ trpcClient, events }` tuple. +5. **Cosmos PRs:** confirm appetite to author the principled deltas (rename, + typed init, client-first hook) as PRs on their repo so both surfaces match. + +### 12.11 Decisions taken (2026-06-29) + +Strategy locked: **Option C** (§12.6) — design the best package for the primary +audience, align both repos, author PRs on Cosmos for the principled deltas. + +| # | Decision | Status | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| 1 | Subpath names `./host` + `./webview` (+ `./react`, `.` shared) | ✅ Confirmed | +| 2 | Front door: **factory (2a)** as the greenfield front door + class retained for stateful/method-rich views; `setupTrpc` primitive is the embedder path | ✅ Confirmed (see §12.12) | +| 3 | Ship `initWebviewTrpc<TContext>()` typed-init helper (kills the `ctx as T` cast) | ✅ Confirmed | +| 4 | Separate hooks: `useTrpcClient()` → client, `useRpcEvents()` → channel | ✅ Confirmed | +| 5 | Author the principled deltas as PRs on Cosmos's repo | ✅ Confirmed | + +**Q2 front-door options under consideration:** + +- **2a — Factory primary + class for stateful views.** `openWebview(ctx, options)` + is the documented front door (zero ceremony); `class extends WebviewController` + stays public for views that need instance state, methods, or lifecycle + overrides. The factory is implemented as `new WebviewController(...)`, so the + two are layered, not competing. +- **2b — Class only, modernised to a single options-bag constructor.** One front + door. Captures most of the factory's friendliness by replacing today's six + positional constructor args with one options object; no second API surface. +- **2c — Factory only (class hidden).** Smallest public surface, but pushes + stateful/method-rich views (our `CollectionView`, `DocumentView`) into + callback-via-options patterns; likely too restrictive. + +> **What Cosmos actually does (evidence).** Their _package_ ships **no** panel +> front door at all — neither factory nor class; it owns zero panel logic. On +> the consumer side they use their own `BaseTab` **class** +> (`DocumentTab` / `QueryEditorTab` / `MigrationAssistantTab` all +> `extends BaseTab`), create the panel themselves with +> `vscode.window.createWebviewPanel(...)`, then call the free function +> `setupTrpc(this.panel, ctx, router, callerFactory)`. So the panel front door +> is **class-based in both extensions**; the factory-vs-class question exists +> for us _only because we choose to own the panel_ (Layer 3), which their +> package deliberately does not. + +Refined lean (2026-06-29): **2b now** — stay class-only and modernise the +constructor to a single options bag — because we are class-only today, our views +are stateful, and Cosmos is also class-based (`BaseTab`). Keep **2a** as a cheap +_additive future_ option: the factory is literally `return new WebviewController(...)`, +so it can be added later if a stateless one-liner case (e.g. in the starter kit) +proves common. This supersedes the earlier 2a lean. + +**Decision (2026-06-29): 2a.** After researching whether the factory would ever +serve Cosmos (§12.12) — it does not, and _by design_ — the factory is confirmed +as a **purely greenfield** convenience with no cross-consumer constraint. We +ship three coexisting tiers (progressive disclosure): + +1. **Factory `openWebview(ctx, options)`** — the greenfield front door; least + ceremony for "I just want a webview." +2. **Class `WebviewController`** — retained escape hatch for stateful / + method-rich / lifecycle-overriding views. The factory is implemented as + `return new WebviewController(...)`, so the two never diverge. +3. **Primitive `setupTrpc(panel, …)`** — bring-your-own-panel, for embedders + like Cosmos who own their panel lifecycle. + +The class constructor is still modernised to a single options bag so dropping +from factory → class is not an ergonomics cliff. 2c (factory-only) is rejected: +§12.12 shows method-rich panels genuinely need class semantics. + +### 12.12 Does the panel-owning factory serve Cosmos? (researched 2026-06-29) + +**Verdict: no — and that is by design.** A subagent read Cosmos's full +`BaseTab` + the three panel subclasses (`DocumentTab`, `QueryEditorTab`, +`MigrationAssistantTab`) on `main`. None could adopt our panel-owning factory; +all are a textbook fit for the `setupTrpc` **primitive** they already use. + +The blockers are architectural, not configuration: + +- **Externally-called typed instance methods.** `QueryEditorTab` exposes + `isActive()`, `isVisible()`, `getCurrentQueryResults()`, `getCurrentQuery()`, + `getSelectedQuery()`, `getConnection()`, `updateQuery()`, `sendSchemaToWebview()` + — called across their `src/chat/*` subsystem. A generic factory **handle** + (`panel` / `onDisposed` / `revealToForeground` / `dispose` / `isDisposed`) + cannot express these; a **subclass** is required. +- **Static create-or-reveal registries.** `static render(...)` plus + `static openTabs` / `instances` maps (dedupe by document id / endpoint / + workspace folder) own panel creation _before any panel exists_ — outside + anything a per-panel factory returns. +- **Custom HTML/CSP + a different initial-data convention.** Monaco + `worker-src … blob:`, a loading spinner, and `globalThis.l10n_bundle` + injection; initial state flows **post-load over tRPC**, not via our + `window.config.__initialData` snapshot. +- Compatible only on narrow axes: webview options + (`enableScripts`/`retainContextWhenHidden` — our fixed set is a superset) and + no `WebviewPanelSerializer` is used anywhere (0 matches). + +**Implications for the design:** + +1. The factory is **purely greenfield**; shipping it neither helps nor hinders + Cosmos. What enables Cosmos is the **primitive** `setupTrpc` (R1) being + public, plus middleware bodies (R3), the event channel (R4), and the + `./host` / `./webview` split (R2). +2. The research **positively confirms the class escape hatch must stay** — + method-rich panels (theirs today, possibly ours tomorrow) genuinely need + class semantics — which is why 2c (factory-only) is rejected. +3. It also validates the tier boundary: Cosmos lives at Layers 0–2 + the + primitive; our factory/class is Layer 3 and serves only the + "just want a webview" audience. The two never collide. + +### 12.13 Naming decisions (2026-06-29) + +Confirmed verb system: **open** (create+show a panel) · **attach** (host ↔ an +existing panel) · **connect** (webview ↔ host) · **init/create** (plumbing) · +**use** (React hooks). Verbs do things; the one noun (`WebviewController`) is the +thing you hold or extend. + +| Role | Name → return | Subpath | Status | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------- | ------ | +| Factory (greenfield front door) | `openWebview(ctx, options)` → `WebviewController` | `./host` | ✅ | +| Class (escape hatch, factory returns its instance) | `WebviewController` | `./host` | ✅ | +| Host primitive (bring-your-own-panel) | `attachTrpc(panel, ctx, router, callerFactory)` → `{ disposable, … }` | `./host` | ✅ | +| **Webview primitive (parallel to `attachTrpc`)** | `connectTrpc(vscodeApi, options?)` → `{ client, events }` | `./webview` | ✅ | +| Event channel (building block) | `createEventChannel()` → `RpcEventChannel` | `./webview` | ✅ | +| Typed init | `initWebviewTrpc<TContext>()` | `.` | ✅ | +| Links | `vscodeLink`, `errorLink` | `./webview` | ✅ | +| Middleware bodies + adapters | `loggingMiddlewareBody`, `telemetryMiddlewareBody`, `ProcedureLogger`, `TelemetryRunner` | `./host` | ✅ | +| React hooks | `useTrpcClient()` → client, `useRpcEvents()` → channel | `./react` | ✅ | + +The symmetric primitive pair: + +```ts +// extension host — attach a dispatcher to a panel you own +const { disposable } = attachTrpc(panel, ctx, appRouter, createCallerFactory); + +// webview — connect a client to the host (bundles createEventChannel + vscodeLink + errorLink) +const { client, events } = connectTrpc<AppRouter>(vscodeApi); +``` + +`createEventChannel()` stays as the lower building block that `connectTrpc` +uses internally and that advanced consumers can still wire by hand. The React +hooks are thin wrappers over `connectTrpc` (one memoised instance per webview): +`useTrpcClient()` returns `.client`, `useRpcEvents()` returns `.events`. + +> All naming is now locked; **§13** is the consolidated, authoritative decision +> set. Aligning the inline snippets in §7–§12 to the final names is an optional +> mechanical follow-up (§13.11). + +--- + +## 13. Decisions summary (consolidated) + +**Date:** 2026-06-29 · **Status:** locked. **Single source of truth** — supersedes +naming and recommendations in §7–§12 wherever they differ. + +### 13.1 Strategy & principles + +- **Option C** — design the best package for the primary audience, keep the + embedding case possible, align both repos (author PRs on Cosmos). +- **North star** — "just want a webview" gets the simplest possible API; the + embedding/advanced audience is first-class but secondary. +- **Self-hosting-facade rule** — every convenience is built on the package's own + public primitives; nothing on the happy path is reachable _only_ through the + facade. Structural guarantee against "too bundled." + +### 13.2 Subpaths + +| Entry | Side | Holds | +| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `.` | shared (no `vscode`, no React) | wire types, `TypedEventSink`, `BaseRouterContext`, `initWebviewTrpc`, re-exported tRPC `router` / `publicProcedure` | +| `./host` | extension host | `openWebview`, `WebviewController`, `attachTrpc`, middleware bodies + adapters | +| `./webview` | webview (framework-agnostic) | `connectTrpc`, `createEventChannel`, `vscodeLink`, `errorLink`, wire types | +| `./react` | webview (React) | `useTrpcClient`, `useRpcEvents`, `WithWebviewContext`, `useConfiguration` | + +### 13.3 Three front-door tiers (progressive disclosure) + +| Tier | API | For | +| --------- | ------------------------------------------------------ | ---------------------------------------- | +| Factory | `openWebview(ctx, options)` → `WebviewController` | greenfield; least ceremony | +| Class | `class … extends WebviewController` (options-bag ctor) | stateful / method-rich / lifecycle views | +| Primitive | `attachTrpc(panel, ctx, router, callerFactory)` | bring-your-own-panel (Cosmos) | + +The factory returns a `WebviewController` instance (no separate handle type). +**2c (factory-only) rejected** — method-rich panels need class semantics (§12.12). + +### 13.4 Final public names + +| Role | Name → return | Subpath | +| ---------------------------- | ---------------------------------------------------------------------------------------- | ----------- | +| Factory | `openWebview(ctx, options)` → `WebviewController` | `./host` | +| Class | `WebviewController` | `./host` | +| Host primitive | `attachTrpc(panel, ctx, router, callerFactory)` → `{ disposable, … }` | `./host` | +| Webview primitive | `connectTrpc(vscodeApi, options?)` → `{ client, events }` | `./webview` | +| Event channel | `createEventChannel()` → `RpcEventChannel` (`onSuccess`/`onError`/`onAborted`) | `./webview` | +| Typed init | `initWebviewTrpc<TContext>()` | `.` | +| Links | `vscodeLink`, `errorLink` | `./webview` | +| Middleware bodies + adapters | `loggingMiddlewareBody`, `telemetryMiddlewareBody`, `ProcedureLogger`, `TelemetryRunner` | `./host` | +| React hooks | `useTrpcClient()` → client, `useRpcEvents()` → channel | `./react` | + +**Verb system:** `open` (panel) · `attach` (host ↔ panel) · `connect` +(webview ↔ host) · `init` / `create` (plumbing) · `use` (hooks). Verbs act; the +lone noun `WebviewController` is the thing you hold or extend. + +### 13.5 Telemetry & observability + +- **Retire** `createMiddleware` (bound to the package instance), + `publicProcedureWithTelemetry`, and the `TelemetryContext` model. +- **Ship** instance-agnostic **middleware bodies** + **adapter interfaces**; + keep a `consoleProcedureLogger` zero-config default. +- Client-side observation via `createEventChannel` / `RpcEventChannel`; + `errorLink` becomes a thin shim over the channel. + +### 13.6 Typed-init kills the cast + +`initWebviewTrpc<TContext>()` returns `{ router, publicProcedure, createCallerFactory, … }` +bound to the consumer's context type, so procedures are fully typed with **no +`ctx as RouterContext` cast**. The facade supplies the `createCallerFactory` to +`attachTrpc` internally, so the simple user never sees it. + +### 13.7 vs today's package + +| Keep | Retire | Add | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| wire protocol, subscription/abort lifecycle, `safePostMessage`, `TypedEventSink`, `vscodeLink` | panel-owning monolith coupling; `createMiddleware` / `publicProcedureWithTelemetry` / `TelemetryContext`; React bundled into `.` | `openWebview`, `attachTrpc`, `connectTrpc`, `initWebviewTrpc`, middleware bodies + adapters, event channel, `./host` / `./webview` / `./react` split | + +### 13.8 Cosmos alignment + +- Cosmos consumes the **`attachTrpc` primitive** (bring-your-own-panel) with + their own `BaseTab`; they never touch `openWebview` (researched, §12.12). +- We author PRs on their repo for the principled deltas: `setupTrpc` → `attachTrpc`, + `./server` → `./host` / `./client` → `./webview`, `initWebviewTrpc`, + client-first hooks. + +### 13.9 Build & packaging + +- **No Vite.** Stay on `tsc -p .` → `dist/` (package) and webpack + `swc` + (bundles). Subpath `exports` + `typesVersions` use the same mechanism + `./server` already uses today. +- `@trpc/*` and `react` stay **peers** (`react` optional). Move `TypedEventSink` + - wire types to the shared `.` entry. +- ESM (`"type": "module"`) flip is optional and independent — not required. + +### 13.10 Effort + +**~1000 LOC (low-thousands)** programme: package rework ~1000, our consumer +update ~100, Cosmos migration ~100–1000 authored + ~1000 deleted. Mostly +_moving_ already-hardened code, not net-new logic. + +### 13.11 Follow-up (mechanical) + +A rename pass aligning the inline snippets in §7–§12 (`setupTrpc` → `attachTrpc`, +etc.) to these final names remains an optional cleanup; §13 is authoritative +in the meantime. diff --git a/jest.config.js b/jest.config.js index 7127b20c7..80464966e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,6 +19,6 @@ module.exports = { '<rootDir>/packages/documentdb-js-schema-analyzer', '<rootDir>/packages/documentdb-js-operator-registry', '<rootDir>/packages/documentdb-js-shell-runtime', - '<rootDir>/packages/vscode-ext-react-webview', + '<rootDir>/packages/vscode-ext-webview', ], }; diff --git a/package-lock.json b/package-lock.json index 42c4c116c..ce4cc340c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@microsoft/vscode-azext-azureutils": "~4.2.0", "@microsoft/vscode-azext-utils": "~4.1.0", "@microsoft/vscode-azureresources-api": "~2.5.0", - "@microsoft/vscode-ext-react-webview": "*", + "@microsoft/vscode-ext-webview": "*", "@monaco-editor/react": "~4.7.0", "@mongodb-js/explain-plan-helper": "1.4.24", "@mongodb-js/shell-bson-parser": "^1.5.6", @@ -5413,8 +5413,8 @@ "@azure/ms-rest-azure-env": "^2.0.0" } }, - "node_modules/@microsoft/vscode-ext-react-webview": { - "resolved": "packages/vscode-ext-react-webview", + "node_modules/@microsoft/vscode-ext-webview": { + "resolved": "packages/vscode-ext-webview", "link": true }, "node_modules/@microsoft/vscode-processutils": { @@ -26757,9 +26757,9 @@ "mongodb": ">=6.0.0" } }, - "packages/vscode-ext-react-webview": { - "name": "@microsoft/vscode-ext-react-webview", - "version": "0.8.0-preview", + "packages/vscode-ext-webview": { + "name": "@microsoft/vscode-ext-webview", + "version": "0.9.0-preview", "license": "MIT", "peerDependencies": { "@trpc/client": "^11.0.0", @@ -26768,6 +26768,9 @@ "vscode-webview": "^1.0.0" }, "peerDependenciesMeta": { + "react": { + "optional": true + }, "vscode-webview": { "optional": true } diff --git a/package.json b/package.json index 44ece2169..5e87941b7 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "@documentdb-js/operator-registry": "*", "@documentdb-js/shell-runtime": "*", "@documentdb-js/schema-analyzer": "*", - "@microsoft/vscode-ext-react-webview": "*", + "@microsoft/vscode-ext-webview": "*", "@vscode/l10n": "~0.0.18", "acorn": "^8.16.0", "acorn-walk": "^8.3.5", diff --git a/packages/vscode-ext-react-webview/README.md b/packages/vscode-ext-react-webview/README.md deleted file mode 100644 index c63b0c940..000000000 --- a/packages/vscode-ext-react-webview/README.md +++ /dev/null @@ -1,593 +0,0 @@ -# @microsoft/vscode-ext-react-webview (Preview) - -> ⚠️ **Preview release.** This package is published in preview while the API -> surface stabilises. Breaking changes may land between minor versions until -> a `1.0.0` release. - -Webview infrastructure for VS Code extensions with type-safe tRPC RPC over -`postMessage`, React hooks for the webview side, and a pluggable telemetry -middleware. - -The package was extracted from the webview stack powering the -[DocumentDB for VS Code](https://github.com/microsoft/vscode-documentdb) and -[Azure Cosmos DB for VS Code](https://github.com/microsoft/vscode-cosmosdb) -extensions, then refined against the public -[vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) -reference repository. - ---- - -## Architecture - -`WebviewController` lives on the extension-host side and owns a -`vscode.WebviewPanel`. The webview side runs the React app inside the panel -and talks to the host through tRPC over `window.postMessage`. There is no -HTTP, no WebSocket, and no string-typed protocol to maintain by hand. tRPC -types flow from the router definition to the React component that calls -into it. - -``` -Extension Host (Node.js) Webview (Browser) -┌────────────────────────────┐ ┌────────────────────────────┐ -│ WebviewController │ │ React tree │ -│ ├─ router (tRPC) │◄──────►│ ├─ WithWebviewContext │ -│ ├─ procedures │ post │ ├─ useTrpcClient │ -│ ├─ createMiddleware │ Msg │ ├─ useConfiguration │ -│ └─ AbortSignal in ctx │ │ └─ vscodeLink (transport) │ -└────────────────────────────┘ └────────────────────────────┘ - imported from /server imported from main entry -``` - -The same `AppRouter` type is shared by both sides: define the router once -on the extension host, then call it from the webview with full type -inference, auto-completion, and refactor-safety. - -## Quick start - -The shortest path to a working webview is four files. The example below -defines one procedure and renders a button in the webview that calls it. - -> For a complete extension layout with build configuration, accessibility -> helpers, Monaco wiring, and tested-end-to-end command registration, copy -> the [vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) -> instead of starting from these snippets. The starter kit is the canonical -> consumer reference; this section is for understanding the moving parts. - -**1. Install** - -```bash -npm install @microsoft/vscode-ext-react-webview -``` - -The package declares `react`, `@trpc/client`, and `@trpc/server` as peer -dependencies. Bring whatever versions you use yourself; the package will -not pull duplicates into your webview bundle. `react-dom` is *not* a peer -of this package — it is a transitive concern of any React DOM app shell. - -**2. Define the router (extension host)** - -```ts -// src/webviews/_integration/appRouter.ts -import { publicProcedure, router, type BaseRouterContext } from '@microsoft/vscode-ext-react-webview/server'; -import { z } from 'zod'; - -export type RouterContext = BaseRouterContext & { - // application-specific fields, e.g.: - workspaceRoot: string; -}; - -export const appRouter = router({ - hello: publicProcedure - .input(z.object({ name: z.string() })) - .query(({ input }) => ({ greeting: `Hello, ${input.name}!` })), -}); - -export type AppRouter = typeof appRouter; -``` - -**3. Create a controller (extension host)** - -```ts -// src/webviews/_integration/MyViewController.ts -import * as vscode from 'vscode'; -import { WebviewController } from '@microsoft/vscode-ext-react-webview/server'; -import { appRouter, type AppRouter, type RouterContext } from './appRouter'; - -export class MyViewController extends WebviewController<AppRouter, MyViewConfig, RouterContext> { - constructor(extensionContext: vscode.ExtensionContext, title: string) { - super( - extensionContext, - title, - 'myView', // viewType key; matches the React component registration - { initialMessage: 'ready' } satisfies MyViewConfig, - { - appRouter, - isBundled: extensionContext.extensionMode === vscode.ExtensionMode.Production, - sourceLayout: { - bundled: { dir: '', file: 'views.js' }, - dev: { dir: 'out/src/webviews', file: 'index.js' }, - }, - devServerHost: 'http://localhost:18080', - }, - ); - - this.setupTrpc({ - workspaceRoot: vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? '', - } satisfies RouterContext); - } -} - -type MyViewConfig = { initialMessage: string }; -``` - -Open the panel from a command: - -```ts -// src/extension.ts -import * as vscode from 'vscode'; -import { MyViewController } from './webviews/_integration/MyViewController'; - -export function activate(ctx: vscode.ExtensionContext) { - ctx.subscriptions.push( - vscode.commands.registerCommand('myExtension.openMyView', () => { - new MyViewController(ctx, 'My View'); - }), - ); -} -``` - -**4. Render the view (webview / browser)** - -The webview entry point exports a `render(viewType, vscodeApi)` function -that the framework's HTML scaffold calls when the panel loads. The -`viewType` argument is the key you passed to `WebviewController` (here, -`'myView'`); use it to look up the matching React component. - -```tsx -// src/webviews/index.tsx -import { createRoot } from 'react-dom/client'; -import { WithWebviewContext, type WebviewState } from '@microsoft/vscode-ext-react-webview'; -import type { WebviewApi } from 'vscode-webview'; -import { MyView } from './myView/MyView'; - -const registry = { - myView: MyView, -} as const; - -export function render(viewType: keyof typeof registry, vscodeApi: WebviewApi<WebviewState>) { - const Component = registry[viewType]; - createRoot(document.getElementById('root')!).render( - <WithWebviewContext vscodeApi={vscodeApi}> - <Component /> - </WithWebviewContext>, - ); -} -``` - -```tsx -// src/webviews/myView/MyView.tsx -import { useEffect, useState } from 'react'; -import { useTrpcClient, useConfiguration } from '@microsoft/vscode-ext-react-webview'; -import type { AppRouter } from '../_integration/appRouter'; - -type MyViewConfig = { initialMessage: string }; - -export const MyView = () => { - const config = useConfiguration<MyViewConfig>(); - const { trpcClient } = useTrpcClient<AppRouter>(); - const [greeting, setGreeting] = useState(config.initialMessage); - - useEffect(() => { - void trpcClient.hello.query({ name: 'world' }).then((r) => setGreeting(r.greeting)); - }, [trpcClient]); - - return <h1>{greeting}</h1>; -}; -``` - -That is the complete data path: the React component calls -`trpcClient.hello.query(...)`, the call travels through `vscodeLink` as a -`postMessage`, the host-side `WebviewController` dispatches it to -`appRouter.hello`, the result is `postMessage`d back, and the call promise -resolves with full type inference for `r.greeting`. - -## Starter kit and reference consumers - -The recommended way to start a new consumer is to copy the -[vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) -and adapt it. The starter kit covers things the package intentionally -does not own: - -- webpack / Vite build configuration for both the extension and the - views bundle; -- accessibility helpers (an ARIA `Announcer`, a selective context-menu - prevention hook); -- a Monaco editor integration recipe; -- a worked demo view exercising the tRPC client end to end. - -A consumer-side integration layer (router + controller base + telemetry -sink + configuration knobs) typically lives in a folder like -`src/webviews/_integration/`. The underscore prefix sorts the folder -above feature folders in the file explorer, which is the conventional -"infrastructure / not feature code" signal. The -[vscode-documentdb](https://github.com/microsoft/vscode-documentdb) and -[Azure Cosmos DB for VS Code](https://github.com/microsoft/vscode-cosmosdb) -extensions are working examples of that layout against this package. - -> **Note about freshness.** The package is `0.8.0-preview` and the -> starter kit may lag a release while the surface stabilises. When the -> kit is not yet on the latest preview, fall back to the README and the -> in-tree consumer in vscode-documentdb for the current shape. - -## What's inside - -- **`WebviewController`**: manages a `vscode.WebviewPanel`, dispatches - incoming tRPC operations (queries, mutations, subscriptions), and handles - abort / subscription cancellation lifecycle. -- **`TypedEventSink<T>`**: a small typed async-iterable used to bridge - push-style domain events (event emitters, callbacks) into tRPC - subscriptions. See [Advanced · Push events from the extension host to - the webview](#push-events-from-the-extension-host-to-the-webview). -- **`vscodeLink`**: a custom tRPC link that bridges tRPC over - `window.postMessage`. Type-safe end-to-end from the extension host to the - React webview. -- **`errorLink`**: optional tRPC link that forwards query/mutation errors - to a consumer-supplied handler (announce, toast, telemetry) without - preventing the normal error flow. See [Advanced · Webview-side error - observer](#webview-side-error-observer). -- **React hooks**: `useTrpcClient`, `useConfiguration`. -- **Webview context**: `WebviewContext`, `WithWebviewContext` for wiring up - the React tree. -- **Pluggable telemetry middleware**: generic `TelemetryContext`, a - `createMiddleware` factory, and a default `console.log` sink. Plug in - your own instrumentation (e.g. Application Insights) by writing a custom - middleware. - -## Entry points - -The package has two separate entry points so bundlers do not drag Node / -VS Code APIs into the webview bundle. - -```ts -// Webview (browser) side. No Node / vscode imports. -import { useTrpcClient, useConfiguration, WithWebviewContext } from '@microsoft/vscode-ext-react-webview'; - -// Extension host side. Uses fs, path, vscode. -import { - WebviewController, - router, - publicProcedure, - createMiddleware, - TypedEventSink, - type BaseRouterContext, -} from '@microsoft/vscode-ext-react-webview/server'; -``` - -## Peer dependencies - -| Package | Required version | -| ---------------- | -------------------------------------- | -| `react` | `>=18.0.0` | -| `@trpc/client` | `^11.0.0` | -| `@trpc/server` | `^11.0.0` | -| `vscode-webview` | `^1.0.0` (optional, webview-side only) | - -## Scope - -This package ships **only the webview transport** (tRPC over `postMessage`) -and the minimum React glue to consume it. UI components, UX policy -(context-menu handling, focus management, etc.), accessibility helpers, -editor-specific behaviours, and other consumer concerns are out of scope -by design. Keep them in your application repository or pick dedicated -libraries for them. - -## Advanced - -### Sharing a single tRPC client across components - -By default, every component that calls `useTrpcClient()` receives its own -client instance. The instance is stable across re-renders (via `useMemo`), -but separate components hold separate clients. - -This is intentional. Each component is self-contained: a developer can open -any file, see the `useTrpcClient()` call, follow the symbol, and understand -the entire transport pipeline without tracing through a provider hierarchy. -For views with a handful of components this is the recommended approach. - -If your view grows past ~10 components that each create their own client, -prefer sharing a single instance through a React context: - -```tsx -// TrpcContext.tsx -import { createTRPCClient, loggerLink, type CreateTRPCClient } from '@trpc/client'; -import { createContext, useContext, useMemo } from 'react'; -import { - vscodeLink, - WebviewContext, - type VsCodeLinkRequestMessage, - type VsCodeLinkResponseMessage, -} from '@microsoft/vscode-ext-react-webview'; -import type { AppRouter } from '../_integration/appRouter'; - -const TrpcContext = createContext<CreateTRPCClient<AppRouter>>({} as CreateTRPCClient<AppRouter>); - -export const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { vscodeApi } = useContext(WebviewContext); - - const trpcClient = useMemo(() => { - const send = (m: VsCodeLinkRequestMessage) => vscodeApi.postMessage(m); - const onReceive = (cb: (m: VsCodeLinkResponseMessage) => void) => { - const handler = (e: MessageEvent) => { - if ((e.data as VsCodeLinkResponseMessage).id) cb(e.data as VsCodeLinkResponseMessage); - }; - window.addEventListener('message', handler); - return () => window.removeEventListener('message', handler); - }; - return createTRPCClient<AppRouter>({ - links: [loggerLink(), vscodeLink<AppRouter>({ send, onReceive })], - }); - }, [vscodeApi]); - - return <TrpcContext.Provider value={trpcClient}>{children}</TrpcContext.Provider>; -}; - -export const useSharedTrpcClient = () => useContext(TrpcContext); -``` - -Then wrap your view tree: - -```tsx -<WithWebviewContext vscodeApi={vscodeApi}> - <TrpcProvider> - <Component /> - </TrpcProvider> -</WithWebviewContext> -``` - -| Pros | Cons | -| --------------------------------------------- | -------------------------------------------------- | -| One client instance, one `message` listener | Requires a provider wrapping the component tree | -| Central place to configure or swap the client | Extra indirection: trace through the provider tree | -| Scales past a dozen components | Provider-ordering mistakes can be hard to debug | - -For most views the per-component default is simpler and sufficient. - -### Webview-side error observer - -By default a query or mutation that throws on the extension host side -propagates to the call-site `.catch(...)` (or `try/catch` around `await`). -That works, but it forces every call site to remember to handle the -error. - -When there is a single place that should always see webview-side errors -(an ARIA `Announcer`, a `Toaster`, telemetry), pass an `onError` callback -to `useTrpcClient`. The hook installs an `errorLink` for you: - -```tsx -const { trpcClient } = useTrpcClient<AppRouter>({ - onError: (err) => announcer.announceError(err.message), -}); -``` - -Semantics: - -- The callback fires for query and mutation errors, **in addition to** the - normal tRPC error flow. Your call-site `.catch(...)` handlers still run - and still receive the error. The link observes, it does not swallow. -- Subscription errors are intentionally **not** forwarded to the callback. - Subscriptions have their own per-call `.subscribe({ onError })` hook - that gives the call site enough control; forwarding here would surface - the error twice. -- The callback is captured into the tRPC client at memo time. Pass a - stable reference (e.g. `useCallback`) or accept that changing the - callback identity will rebuild the client on the next render. - -If you need finer control over the link order (e.g. composing with a -third-party logging link), import `errorLink` directly and skip the -option: - -```ts -import { createTRPCClient, loggerLink } from '@trpc/client'; -import { errorLink, vscodeLink } from '@microsoft/vscode-ext-react-webview'; - -const trpcClient = createTRPCClient<AppRouter>({ - links: [ - loggerLink(), - errorLink<AppRouter>((err) => announcer.announceError(err.message)), - vscodeLink<AppRouter>({ send, onReceive }), - ], -}); -``` - -### Push events from the extension host to the webview - -tRPC subscriptions are modeled as `async function*` generators. That shape -fits **pull**-style producers (cursors, polling loops). For **push**-style -producers (VS Code event emitters, driver callbacks, completion notifiers) -you need a small adapter: something the producer can call imperatively, and -that the subscription procedure can iterate. - -`TypedEventSink<T>` is that adapter. It implements `AsyncIterable<T>` over -a discriminated event union, with a write-only `emit(event)` (or -`emit(type, payload)`) on the producer side and `for await (const event of -sink)` on the consumer side. Single consumer per sink; events emitted -before a consumer attaches are buffered. - -Define the event union, then the events router: - -```ts -// _integration/myViewEventsRouter.ts (or co-located with the view) -import { publicProcedureWithTelemetry, router } from './appRouter'; -import type { TypedEventSink } from '@microsoft/vscode-ext-react-webview/server'; - -export type MyViewEvent = { type: 'progress'; percent: number } | { type: 'completed'; durationMs: number }; - -type MyViewRouterContext = BaseRouterContext & { - eventSink: TypedEventSink<MyViewEvent>; -}; - -export const myViewEventsRouter = router({ - events: publicProcedureWithTelemetry.subscription(async function* ({ ctx }) { - const sink = (ctx as MyViewRouterContext).eventSink; - for await (const event of sink) { - if (ctx.signal?.aborted) return; - yield event; - } - }), -}); -``` - -Emit events from anywhere in extension-host code that holds the sink: - -```ts -sink.emit({ type: 'progress', percent: 25 }); -sink.emit('completed', { durationMs: 1500 }); -``` - -Consume them in the webview with the standard tRPC subscription API: - -```tsx -useEffect(() => { - const sub = trpcClient.myView.events.subscribe(undefined, { - onData: (event) => { - if (event.type === 'progress') setPercent(event.percent); - else if (event.type === 'completed') setDoneAfter(event.durationMs); - }, - }); - return () => sub.unsubscribe(); -}, [trpcClient]); -``` - -Recommended convention: put push-event procedures in a sibling -`<view>EventsRouter.ts` and merge it into the view's main router. This -keeps "things the webview calls" and "things the host pushes" in separate -files, which makes it easy to discover the entire event vocabulary of a -view at a glance. - -When the producer (panel, session, task) finishes for good, call -`sink.close()`. The framework already cleans up async iteration on -unsubscribe and panel disposal via `iterator.return()`, but calling -`close()` is still the right signal whenever the sink itself has no more -events to ever emit — it lets late producers stop without checking, and -it prevents reuse by accident. - -### Importing the router type into webview code - -The webview side of your application needs the `AppRouter` type to keep -`useTrpcClient<AppRouter>()` calls type-safe end to end. This is the only -thing webview code should import from the file that defines your router. - -```tsx -// In a webview component -import type { AppRouter } from '../_integration/appRouter'; -import { useTrpcClient } from '@microsoft/vscode-ext-react-webview'; - -const { trpcClient } = useTrpcClient<AppRouter>(); -``` - -A few rules of thumb keep the host/browser boundary honest: - -- Use `import type { AppRouter } from '...'` (or - `import { type AppRouter }`). Type-only imports are erased at compile - time and never produce runtime references, so the webview bundle stays - free of extension-host code even if the router module also imports - Node-only APIs. -- Do not import runtime values (the `appRouter` constant, procedure - builders, middleware) from webview code. Those belong to the - extension-host side. -- Do not reach into procedure implementation files from the webview side. - Router types are the only contract the webview consumes. - -If your bundler ever pulls server modules into the views bundle, the most -common cause is a non-type-only import of `AppRouter`. Switching it to -`import type { ... }` resolves it without changes to the router itself. - -## FAQ - -### Why tRPC instead of raw `postMessage`? - -Raw `postMessage` requires you to define message types manually, match -request/response pairs by hand, and serialise yourself. tRPC threads -TypeScript types end-to-end. Your extension-host procedures and webview -calls share the same `AppRouter` type with zero code generation. Renaming a -field on the server shows a compile error in the webview immediately. - -### Why are there two entry points (main and `/server`)? - -The main entry is browser-safe and exports only the webview-client surface. -The `/server` subpath exports the extension-host surface, which imports -Node and `vscode`. Splitting them prevents bundlers from dragging -`fs` / `path` / `vscode` into the webview bundle when you import a hook -like `useTrpcClient`. - -### How do I plug in my own telemetry sink (Application Insights, etc.)? - -Build a middleware with `createMiddleware` from `/server`, wire it onto -`publicProcedure`, and export your own `publicProcedureWithTelemetry`. The -package's default middleware uses `console.log` so the package works -out-of-the-box; replace it for production. Example using -`@microsoft/vscode-azext-utils`: - -```ts -import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; -import { createMiddleware, publicProcedure } from '@microsoft/vscode-ext-react-webview/server'; - -const trpcToTelemetry = createMiddleware(async (opts) => { - const result = await callWithTelemetryAndErrorHandling( - `myExtension.rpc.${opts.type}.${opts.path}`, - async (context) => { - context.errorHandling.suppressDisplay = true; - return opts.next({ ctx: { ...opts.ctx, telemetry: context.telemetry } }); - }, - ); - if (!result) throw new Error(`No result from tRPC call: ${opts.type} ${opts.path}`); - return result; -}); - -export const publicProcedureWithTelemetry = publicProcedure.use(trpcToTelemetry); -``` - -### Can I have multiple webview panels open at the same time? - -Yes. Each `WebviewController` instance owns its own panel and its own tRPC -caller. State is not shared between panels unless you explicitly coordinate -through the extension host (e.g. a singleton service). - -### Can I cancel a long-running query or subscription? - -Yes. Pass an `AbortSignal` on the client side: - -```ts -const ac = new AbortController(); -const result = await trpcClient.something.query(input, { signal: ac.signal }); -// later -ac.abort(); -``` - -On the server side, read `ctx.signal` inside your procedure to cooperatively -stop work. Subscriptions stop cleanly when the client unsubscribes: the -framework sends a `subscription.stop` message and the controller both -aborts the per-operation `AbortController` *and* calls -`iterator.return()` on the procedure's async generator. The `return()` -call propagates through the generator into any inner `for await` loop — -including loops over a `TypedEventSink` — which releases consumers -parked on the next event without waiting for the producer to emit or -close. The same cleanup runs when the controller itself is disposed. - -### Can I use a UI library other than React? - -Not from the main entry. The webview-client hooks (`useTrpcClient`, -`useConfiguration`, `WithWebviewContext`) are React-specific. The underlying -transport (`vscodeLink`) is framework-agnostic, so you can build a binding -for another framework on top of it, but the package itself does not ship one. - -## Status - -`0.8.0-preview`. Version aligns with the parent -[vscode-documentdb](https://github.com/microsoft/vscode-documentdb) extension -that currently ships it. APIs are subject to change while the package is in -preview. - -## License - -MIT. See [LICENSE.md](../../LICENSE.md) at the repository root. diff --git a/packages/vscode-ext-react-webview/jest.config.js b/packages/vscode-ext-react-webview/jest.config.js deleted file mode 100644 index 89b88f3af..000000000 --- a/packages/vscode-ext-react-webview/jest.config.js +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} **/ -module.exports = { - displayName: 'vscode-webview-api', - // Limit workers to avoid OOM kills on machines with many cores. - // Each ts-jest worker loads the TypeScript compiler and consumes ~500MB+. - maxWorkers: '50%', - testEnvironment: 'node', - testMatch: ['<rootDir>/src/**/*.test.ts'], - transform: { - '^.+\\.tsx?$': ['ts-jest', {}], - }, -}; diff --git a/packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts b/packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts deleted file mode 100644 index e8c02823c..000000000 --- a/packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts +++ /dev/null @@ -1,603 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { getTRPCErrorFromUnknown, type AnyRouter } from '@trpc/server'; -import { randomBytes } from 'crypto'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { type VsCodeLinkRequestMessage } from '../webview-client/vscodeLink'; -import { type BaseRouterContext } from './BaseRouterContext'; -import { createCallerFactory } from './trpc'; - -/** - * Describes where the bundled webview JavaScript lives on disk relative to the - * extension root, both when the extension is shipped as a webpack bundle - * (production) and when it is loaded from `tsc` output (development). - * - * The framework picks the appropriate entry based on - * {@link WebviewControllerOptions.isBundled}. In development, if the - * `DEVSERVER` environment variable is set, the controller serves the script - * from {@link WebviewControllerOptions.devServerHost} instead of the on-disk - * location. - */ -export interface WebviewSourceLayout { - /** Layout used when `isBundled` is true (production bundle on disk). */ - bundled: { dir: string; file: string }; - - /** Layout used when `isBundled` is false (loaded from `tsc` output). */ - dev: { dir: string; file: string }; -} - -/** - * Configuration injected into every {@link WebviewController}. - */ -export interface WebviewControllerOptions<TRouter extends AnyRouter> { - /** - * The root tRPC router for this application. The controller dispatches - * incoming webview messages against this router. - */ - appRouter: TRouter; - - /** - * True when the extension is running from its webpack bundle (production), - * false when running from the TypeScript dev output. Determines which - * entry from {@link sourceLayout} is used. - */ - isBundled: boolean; - - /** - * Where the webview JavaScript bundle is located. See {@link WebviewSourceLayout}. - */ - sourceLayout: WebviewSourceLayout; - - /** - * Dev-server URL used when `isBundled` is false and `process.env.DEVSERVER` - * is truthy. Typically `'http://localhost:18080'`. - * - * Defaults to `'http://localhost:18080'` when omitted. - */ - devServerHost?: string; -} - -const DEFAULT_DEV_SERVER_HOST = 'http://localhost:18080'; - -/** - * WebviewController manages a `vscode.WebviewPanel` and provides tRPC-based - * communication with the React webview. It handles incoming requests (queries, - * mutations, and subscriptions) from the webview, routing them to server-side - * procedures defined in the injected `appRouter`. - * - * @template TRouter - The application's root tRPC router type. - * @template TConfiguration - The configuration object passed to the webview at - * creation time (received in the webview via - * {@link useConfiguration}). - * @template TContext - The router context shape (must extend - * {@link BaseRouterContext}). - */ -export class WebviewController< - TRouter extends AnyRouter, - TConfiguration = unknown, - TContext extends BaseRouterContext = BaseRouterContext, -> - implements vscode.Disposable -{ - private _panel: vscode.WebviewPanel; - private _disposables: vscode.Disposable[] = []; - private _isDisposed: boolean = false; - private _onDisposed: vscode.EventEmitter<void> = new vscode.EventEmitter<void>(); - public readonly onDisposed: vscode.Event<void> = this._onDisposed.event; - - /** - * Tracks active subscriptions by their operation ID. - * - * Each entry carries both: - * - * - an `AbortController` used to surface cooperative cancellation through - * `ctx.signal.aborted` to procedure bodies that poll the signal, and - * - the procedure's live `AsyncIterator`, so the framework can call - * `iterator.return()` on `subscription.stop` and on panel disposal. - * That second half is what releases consumers parked on the next - * `next()` call (e.g. `TypedEventSink` waiting for the next `emit`): - * abort signals on their own cannot unblock a parked `next()`, so - * without iterator-protocol cleanup a subscription that has not - * recently emitted would stay parked until the panel was disposed - * *and* the producer happened to close its sink. - */ - private _activeSubscriptions = new Map< - string, - { - abortController: AbortController; - iterator: AsyncIterator<unknown>; - } - >(); - - /** - * A map tracking active queries and mutations by their operation ID. - * Each operation is associated with an AbortController, allowing the server - * side to cancel the operation if the client sends an abort message. - */ - private _activeOperations = new Map<string, AbortController>(); - - private readonly _options: WebviewControllerOptions<TRouter>; - - /** - * Creates a new WebviewController instance. - * - * @param extensionContext The extension context. - * @param title The title of the webview panel. - * @param webviewName The identifier/name for the webview resource. - * Used both as the `viewType` (prefixed with - * `react-webview-`) and as the key passed to the - * webview's `render()` entry point so it can look - * up the matching component from its registry. - * @param configuration The initial state object that the webview will - * use on startup. - * @param options Framework configuration (router, source layout, - * bundling mode). - * @param viewColumn The view column in which to show the new webview - * panel. - * @param _iconPath An optional icon to display in the tab of the - * webview. - */ - constructor( - protected extensionContext: vscode.ExtensionContext, - title: string, - private _webviewName: string, - private configuration: TConfiguration, - options: WebviewControllerOptions<TRouter>, - viewColumn: vscode.ViewColumn = vscode.ViewColumn.One, - private _iconPath?: - | vscode.Uri - | { - readonly light: vscode.Uri; - readonly dark: vscode.Uri; - }, - ) { - this._options = options; - - this._panel = vscode.window.createWebviewPanel('react-webview-' + _webviewName, title, viewColumn, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.file(this.extensionContext.extensionPath)], - }); - - this._panel.webview.html = this.getDocumentTemplate(this._panel.webview); - this._panel.iconPath = this._iconPath; - - // Register the onDisposed emitter so dispose() releases its subscriber list. - // It is intentionally not part of the disposables chain that fires _onDisposed - // itself — dispose() fires the event first, then disposes the rest (this entry - // included), so subscribers see the notification before the emitter is torn down. - this.registerDisposable(this._onDisposed); - - this.registerDisposable( - this._panel.onDidDispose(() => { - this.dispose(); - }), - ); - } - - /** - * Sets up tRPC integration for the webview. This includes listening for - * messages from the webview, parsing them as tRPC operations (queries, - * mutations, subscriptions, or subscription stops), invoking the - * appropriate server-side procedures, and returning results or errors. - * - * @param context - The router context for procedure calls. - */ - protected setupTrpc(context: TContext): void { - this.registerDisposable( - this._panel.webview.onDidReceiveMessage(async (message: VsCodeLinkRequestMessage) => { - switch (message.op.type) { - case 'subscription': - await this.handleSubscriptionMessage(message, context); - break; - - case 'subscription.stop': - this.handleSubscriptionStopMessage(message); - break; - - case 'abort': - this.handleAbortMessage(message); - break; - - default: - await this.handleDefaultMessage(message, context); - break; - } - }), - ); - } - - /** - * Handles the 'subscription' message type. - * - * Sets up an async iterator for the subscription procedure and streams results back - * to the webview. Also handles cancellation via AbortController. - * - * @param message - The original message from the webview. - * @param context - The router context, to which we add an abort signal. - */ - private async handleSubscriptionMessage(message: VsCodeLinkRequestMessage, context: TContext) { - // In v12, tRPC will have better cancellation support. For now, we use AbortController. - const abortController = new AbortController(); - - try { - // Clone context so the signal is per-operation and does not mutate the shared context object - const opContext: TContext = { ...context, signal: abortController.signal }; - - const callerFactory = createCallerFactory(this._options.appRouter); - const caller = callerFactory(opContext); - - // eslint-disable-next-line - const procedure = caller[message.op.path]; - - if (typeof procedure !== 'function') { - // Framework-internal protocol error; not localized — consumers cannot translate it - // and this code path indicates a programming error in the caller (wrong path). - throw new Error(`Procedure not found: ${message.op.path}`); - } - - // Await the procedure call to get the async iterable (the procedure's `async function*` - // result, which is an AsyncGenerator and therefore both iterable and an iterator). - // eslint-disable-next-line , @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment - const asyncIterable = await procedure(message.op.input); - - // Normalize to a live AsyncIterator and store it. We deliberately do *not* use - // `for await (const value of asyncIterable)` because that would obtain the iterator - // internally and give us no handle to call `iterator.return()` on `subscription.stop` - // or panel dispose. Driving `next()`/`return()` ourselves is what lets us release - // consumers parked on a pending next (e.g. an event sink with no recent emit). - const iterator: AsyncIterator<unknown> = this.toAsyncIterator(asyncIterable); - - // Only track the subscription once we actually have an iterator. If procedure - // lookup or the initial `await procedure(...)` throws, we fall through to the - // outer catch without ever inserting an entry — so an early failure cannot - // leave a stale (id, AbortController) pair behind for the lifetime of the panel. - this._activeSubscriptions.set(message.id, { abortController, iterator }); - - void (async () => { - try { - while (true) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { value, done } = await iterator.next(); - if (done) { - break; - } - // Each yielded value is sent to the webview - this.safePostMessage({ id: message.id, result: value }); - } - - // On natural completion (procedure returned, or our `return()` propagated - // through the generator), inform the client. - this.safePostMessage({ id: message.id, complete: true }); - } catch (error) { - const trpcErrorMessage = this.wrapInTrpcErrorMessage(error, message.id); - this.safePostMessage(trpcErrorMessage); - } finally { - this._activeSubscriptions.delete(message.id); - } - })(); - } catch (error) { - const trpcErrorMessage = this.wrapInTrpcErrorMessage(error, message.id); - this.safePostMessage(trpcErrorMessage); - } - } - - /** - * Normalizes a procedure's subscription return value (which may be an - * `AsyncIterable`, an `AsyncIterator`, or both — async generators are - * both) to a single live `AsyncIterator`. - * - * Calling `[Symbol.asyncIterator]()` once is required for iterables like - * {@link TypedEventSink} (which enforce single-consumer semantics); - * direct iterators are returned as-is. - */ - private toAsyncIterator(value: unknown): AsyncIterator<unknown> { - if ( - value !== null && - typeof value === 'object' && - typeof (value as AsyncIterable<unknown>)[Symbol.asyncIterator] === 'function' - ) { - return (value as AsyncIterable<unknown>)[Symbol.asyncIterator](); - } - return value as AsyncIterator<unknown>; - } - - /** - * Handles the 'subscription.stop' message type. - * - * Aborts the per-operation `AbortController` (for procedure bodies that - * poll `ctx.signal.aborted` between yields) and calls the iterator's - * `return()` (for procedure bodies parked on a `for await` over an event - * sink with no recent emit). Either path will end the streaming task - * and run its `finally`, which deletes the map entry. - * - * @param message - The original message from the webview. - */ - private handleSubscriptionStopMessage(message: VsCodeLinkRequestMessage) { - const record = this._activeSubscriptions.get(message.id); - if (record) { - record.abortController.abort(); - // Cooperative abort cannot unblock a parked `iterator.next()`. Calling `return()` - // here propagates through the procedure's async generator into any inner - // `for await` (including `TypedEventSink` consumers), which lets parked - // promises settle with `{ done: true }` and the streaming task exit cleanly. - // We swallow rejection from `return()` because we have no useful reaction. - void Promise.resolve(record.iterator.return?.({ value: undefined, done: true })).catch(() => void 0); - this._activeSubscriptions.delete(message.id); - } - } - - /** - * Handles the 'abort' message type for queries and mutations. - * - * Looks up the active operation by ID and aborts it, allowing the server-side - * procedure to detect cancellation via `ctx.signal.aborted`. - * - * @param message - The original message from the webview. - */ - private handleAbortMessage(message: VsCodeLinkRequestMessage) { - const abortController = this._activeOperations.get(message.id); - if (abortController) { - abortController.abort(); - this._activeOperations.delete(message.id); - } - } - - /** - * Handles the default case for messages (i.e., queries and mutations). - * - * Calls the specified tRPC procedure and returns a single result. - * If the procedure is not found or throws, returns an error message. - * - * @param message - The original message from the webview. - * @param context - The router context. - */ - private async handleDefaultMessage(message: VsCodeLinkRequestMessage, context: TContext) { - // In v12, tRPC will have better cancellation support. For now, we use AbortController. - const abortController = new AbortController(); - this._activeOperations.set(message.id, abortController); - - try { - // Clone context so the signal is per-operation and does not mutate the shared context object - const opContext: TContext = { ...context, signal: abortController.signal }; - - const callerFactory = createCallerFactory(this._options.appRouter); - const caller = callerFactory(opContext); - - // eslint-disable-next-line - const procedure = caller[message.op.path]; - - if (typeof procedure !== 'function') { - // Framework-internal protocol error; not localized — see handleSubscriptionMessage(). - throw new Error(`Procedure not found: ${message.op.path}`); - } - - // eslint-disable-next-line , @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment - const result = await procedure(message.op.input); - - // Only send the result if the operation was not aborted - if (!abortController.signal.aborted) { - // Coalesce undefined → null so the `result` key survives structured-clone - // serialization over postMessage (undefined values are stripped by the - // structured-clone algorithm, which would cause the client-side observable - // to never complete for void mutations). - // eslint-disable-next-line - const response = { id: message.id, result: result ?? null }; - this.safePostMessage(response); - } - } catch (error) { - // Only send error if the operation was not aborted (client already errored locally) - if (!abortController.signal.aborted) { - const trpcErrorMessage = this.wrapInTrpcErrorMessage(error, message.id); - this.safePostMessage(trpcErrorMessage); - } - } finally { - this._activeOperations.delete(message.id); - } - } - - /** - * Converts an unknown error into a tRPC-compatible error response. - * - * By constructing a plain object with enumerable properties, we ensure the client - * receives a properly serialized error object over postMessage. - * - * @param error - The caught error. - * @param operationId - The operation ID associated with the error. - */ - private wrapInTrpcErrorMessage(error: unknown, operationId: string) { - const errorEntry = getTRPCErrorFromUnknown(error); - - return { - id: operationId, - error: { - code: errorEntry.code, - name: errorEntry.name, - message: errorEntry.message, - stack: errorEntry.stack, - cause: errorEntry.cause, - }, - }; - } - - /** - * Safely posts a message to the webview. - * - * Calling {@link vscode.Webview.postMessage} after the panel has been - * disposed can throw synchronously or reject the returned `Thenable` - * depending on the VS Code version. This wrapper guards both shapes so - * that natural races (a subscription generator yielding one more value - * after the panel was closed, an in-flight query resolving after dispose, - * etc.) do not surface as uncaught exceptions in the extension host. - * - * Returns `false` if the message could not be delivered (panel disposed - * or `postMessage` threw); `true` otherwise. The boolean is informational; - * callers do not need to react to it. - */ - private safePostMessage(message: unknown): boolean { - if (this._isDisposed) { - return false; - } - try { - // The Thenable returned by `postMessage` resolves with a delivery - // boolean and rejects if the webview is gone. We attach a no-op - // catch so a late rejection does not surface as an unhandled - // promise rejection. - void Promise.resolve(this._panel.webview.postMessage(message)).catch(() => void 0); - return true; - } catch { - return false; - } - } - - /** - * Generates the full HTML document for the webview, including CSP headers, - * serialized initial configuration, and the script that boots the React app. - */ - private getDocumentTemplate(webview?: vscode.Webview): string { - const devServer = !!process.env.DEVSERVER; - const isProduction = this.extensionContext.extensionMode === vscode.ExtensionMode.Production; - const nonce = randomBytes(16).toString('base64'); - - const layout = this._options.isBundled ? this._options.sourceLayout.bundled : this._options.sourceLayout.dev; - const devServerHost = this._options.devServerHost ?? DEFAULT_DEV_SERVER_HOST; - - const uri = (...parts: string[]) => - webview - ?.asWebviewUri(vscode.Uri.file(path.join(this.extensionContext.extensionPath, layout.dir, ...parts))) - .toString(true); - - const srcUri = isProduction || !devServer ? uri(layout.file) : `${devServerHost}/${layout.file}`; - - const csp = ( - isProduction - ? [ - `form-action 'none';`, - `default-src ${webview?.cspSource};`, - `script-src ${webview?.cspSource} 'nonce-${nonce}';`, - `style-src ${webview?.cspSource} vscode-resource: 'unsafe-inline';`, - `img-src ${webview?.cspSource} data: vscode-resource:;`, - `connect-src ${webview?.cspSource} ws:;`, - `font-src ${webview?.cspSource};`, - `worker-src ${webview?.cspSource} blob:;`, - ] - : [ - `form-action 'none';`, - `default-src ${webview?.cspSource} ${devServerHost};`, - `script-src ${webview?.cspSource} ${devServerHost} 'nonce-${nonce}';`, - `style-src ${webview?.cspSource} ${devServerHost} vscode-resource: 'unsafe-inline';`, - `img-src ${webview?.cspSource} ${devServerHost} data: vscode-resource:;`, - `connect-src ${webview?.cspSource} ${devServerHost} ws:;`, - `font-src ${webview?.cspSource} ${devServerHost};`, - `worker-src ${webview?.cspSource} ${devServerHost} blob:;`, - ] - ).join(' '); - - /** - * Note to code maintainers: - * encodeURIComponent(JSON.stringify(this.configuration)) below is crucial - * We want to avoid the webview from crashing when the configuration object contains 'unsupported' bytes - */ - - return `<!DOCTYPE html> - <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta // noinspection JSAnnotator - http-equiv="Content-Security-Policy" content="${csp}" /> - </head> - <body> - <div id="root"></div> - <script nonce="${nonce}"> - globalThis.l10n_bundle = ${JSON.stringify(vscode.l10n.bundle ?? {})}; - </script> - <script type="module" nonce="${nonce}"> - window.config = { - ...window.config, - __initialData: '${encodeURIComponent(JSON.stringify(this.configuration))}' - }; - - import { render } from "${srcUri}"; - render('${this._webviewName}', acquireVsCodeApi()); - </script> - - </body> - </html>`; - } - - protected registerDisposable(disposable: vscode.Disposable): void { - this._disposables.push(disposable); - } - - /** - * Gets whether the controller has been disposed. - */ - public get isDisposed(): boolean { - return this._isDisposed; - } - - /** - * Gets the vscode.WebviewPanel that the controller is managing. - */ - public get panel(): vscode.WebviewPanel { - return this._panel; - } - - /** - * Reveals the webview in the given column, bringing it to the foreground. - * Useful if the webview is already open but hidden. - * - * @param viewColumn The column to reveal in. Defaults to ViewColumn.One. - */ - public revealToForeground(viewColumn: vscode.ViewColumn = vscode.ViewColumn.One): void { - this._panel.reveal(viewColumn, true); - } - - /** - * Disposes the controller and all registered disposables. - * Aborts all in-flight operations and subscriptions to prevent orphaned work. - * - * **Panel ownership architecture:** The panel owns the controller, not the - * other way around. When the user closes the tab, VS Code disposes the panel, - * which fires `onDidDispose`, which calls `this.dispose()`. We intentionally - * do NOT dispose the panel from within this method — doing so would create a - * circular call chain (`dispose → panel.dispose → onDidDispose → dispose`). - * No code path in the codebase disposes the controller independently of the - * panel, so the panel is always already disposed (or disposing) when we get here. - */ - public dispose(): void { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - - this._onDisposed.fire(); - - // Abort all active queries/mutations so server-side procedures can stop early - for (const controller of this._activeOperations.values()) { - controller.abort(); - } - this._activeOperations.clear(); - - // Abort all active subscriptions and call `return()` on each iterator so async - // generators terminate even when parked on `next()`. The abort signal alone - // cannot unblock a parked `next()`; `return()` propagates through the - // procedure's `for await` into any inner event sink and settles its pending - // promise. Rejections from `return()` are swallowed because we have no useful - // reaction during shutdown. - for (const { abortController, iterator } of this._activeSubscriptions.values()) { - abortController.abort(); - void Promise.resolve(iterator.return?.({ value: undefined, done: true })).catch(() => void 0); - } - this._activeSubscriptions.clear(); - - this._disposables.forEach((d) => { - d.dispose(); - }); - } -} diff --git a/packages/vscode-ext-react-webview/src/extension-server/index.ts b/packages/vscode-ext-react-webview/src/extension-server/index.ts deleted file mode 100644 index d69cac285..000000000 --- a/packages/vscode-ext-react-webview/src/extension-server/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export { type BaseRouterContext } from './BaseRouterContext'; -export { - createCallerFactory, - createMiddleware, - publicProcedure, - publicProcedureWithTelemetry, - router, - type AnyRouter, - type TelemetryContext, - type WithTelemetry, -} from './trpc'; -export { TypedEventSink, type DiscriminatedEvent, type EventOfType, type UntypedEventEmitter } from './TypedEventSink'; -export { WebviewController, type WebviewControllerOptions, type WebviewSourceLayout } from './WebviewController'; diff --git a/packages/vscode-ext-react-webview/src/extension-server/trpc.ts b/packages/vscode-ext-react-webview/src/extension-server/trpc.ts deleted file mode 100644 index 441ec919d..000000000 --- a/packages/vscode-ext-react-webview/src/extension-server/trpc.ts +++ /dev/null @@ -1,155 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * This is your entry point to setup the root configuration for tRPC on the server. - * - `initTRPC` should only be used once per app. - * - We export only the functionality that we use so we can enforce which base - * procedures should be used. - * - * Learn how to create protected base procedures and other things below: - * @see https://trpc.io/docs/v11/router - * @see https://trpc.io/docs/v11/procedures - */ - -import { initTRPC, type AnyRouter } from '@trpc/server'; -import { type BaseRouterContext } from './BaseRouterContext'; - -/** - * Telemetry context interface. - * - * Replace this with your telemetry library's context type when implementing a - * custom middleware. For example, if you use `@microsoft/vscode-azext-utils` - * with Application Insights, you can use `ITelemetryContext` from that package - * as the structural equivalent — both expose `properties` and `measurements` - * records. - */ -export interface TelemetryContext { - properties: Record<string, string>; - measurements: Record<string, number>; -} - -/** - * Helper type: transforms a context type to have required (non-optional) - * telemetry. Use together with `publicProcedureWithTelemetry` (or your own - * telemetry-attaching procedure) to get type-safe telemetry access inside - * procedure handlers. - */ -export type WithTelemetry<T extends { telemetry?: unknown }> = T & { - telemetry: TelemetryContext; -}; - -/** - * Initialization of tRPC backend. - * - * Please note, this should be done only once per backend. - */ -const t = initTRPC.create(); - -/** - * Factory to create a caller (server-side procedure invoker) for a given - * router. Re-exported from tRPC so consumers do not need a direct dependency. - */ -export const createCallerFactory = t.createCallerFactory; - -/** - * Re-exported `router` builder from tRPC. - */ -export const router = t.router; - -/** - * Unprotected base procedure. Use this for procedures that do not need any - * middleware applied. - */ -export const publicProcedure = t.procedure; - -/** - * Factory for tRPC middleware bound to this package's tRPC instance. - * - * Use it to build custom middleware (telemetry sinks, authentication, etc.) - * that compose with {@link publicProcedure} and routers created via - * {@link router}. - * - * @example Custom telemetry middleware with `@microsoft/vscode-azext-utils` - * - * ```typescript - * import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; - * import { createMiddleware, publicProcedure, type BaseRouterContext } from '@microsoft/vscode-ext-react-webview/server'; - * - * const trpcToTelemetry = createMiddleware(async (opts) => { - * const result = await callWithTelemetryAndErrorHandling( - * `myExtension.rpc.${opts.type}.${opts.path}`, - * async (context) => { - * context.errorHandling.suppressDisplay = true; - * return opts.next({ ctx: { ...opts.ctx, telemetry: context.telemetry } }); - * }, - * ); - * if (!result) { - * throw new Error(`No result from tRPC call: ${opts.type} ${opts.path}`); - * } - * return result; - * }); - * - * export const publicProcedureWithTelemetry = publicProcedure.use(trpcToTelemetry); - * ``` - */ -export const createMiddleware = t.middleware; - -/** - * Default telemetry middleware — logs every tRPC call with contextual metadata. - * - * The default implementation uses `console.log` so the package works - * out-of-the-box without any external dependency. For production use, build - * your own middleware via {@link createMiddleware} (see the example above). - */ -const defaultTrpcToTelemetry = t.middleware(async (opts) => { - const telemetry: TelemetryContext = { - properties: {}, - measurements: {}, - }; - - const startTime = Date.now(); - - const result = await opts.next({ - ctx: { - ...opts.ctx, - telemetry, - }, - }); - - const durationMs = Date.now() - startTime; - telemetry.measurements.durationMs = durationMs; - - // Check if the operation was aborted via AbortSignal - const signal = (opts.ctx as BaseRouterContext).signal; - if (signal?.aborted) { - telemetry.properties.aborted = 'true'; - telemetry.properties.result = 'Canceled'; - } - - if (!result.ok) { - if (!signal?.aborted) { - telemetry.properties.result = 'Failed'; - } - telemetry.properties.error = result.error.name; - telemetry.properties.errorMessage = result.error.message; - } - - // Default: log to console. Replace with your telemetry sink. - console.log(`[tRPC] ${opts.type} ${opts.path} (${durationMs}ms)`, telemetry.properties); - - return result; -}); - -/** - * Convenience base procedure that automatically attaches telemetry context - * using the default console-logging middleware. Suitable for getting started; - * for production telemetry, build your own procedure using - * {@link createMiddleware} (see the example on `createMiddleware`). - */ -export const publicProcedureWithTelemetry = publicProcedure.use(defaultTrpcToTelemetry); - -// Re-export key tRPC server types so consumers do not need to import @trpc/server directly. -export type { AnyRouter }; diff --git a/packages/vscode-ext-react-webview/src/index.ts b/packages/vscode-ext-react-webview/src/index.ts deleted file mode 100644 index c3b04b2f6..000000000 --- a/packages/vscode-ext-react-webview/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Public entry point for `@microsoft/vscode-ext-react-webview` — the webview-client - * (browser) surface. - * - * The webview side never needs the extension-server APIs, and pulling them in - * would drag Node / VS Code imports (`fs`, `path`, `vscode`) into the webview - * bundle. Keep this entry browser-only. - * - * Extension-host code imports the server surface from the `/server` subpath: - * - * ```ts - * import { WebviewController, router } from '@microsoft/vscode-ext-react-webview/server'; - * ``` - */ - -export * from './webview-client'; diff --git a/packages/vscode-ext-react-webview/src/server.ts b/packages/vscode-ext-react-webview/src/server.ts deleted file mode 100644 index cde87a24a..000000000 --- a/packages/vscode-ext-react-webview/src/server.ts +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Entry point for the extension-host (server) surface of - * `@microsoft/vscode-ext-react-webview`. - * - * Imported as `@microsoft/vscode-ext-react-webview/server` from the extension's - * Node.js code (controllers, routers, telemetry middleware). Pulls in - * Node / VS Code APIs (`fs`, `path`, `vscode`) and must not be bundled into - * the webview's browser-side code. - */ - -export * from './extension-server'; diff --git a/packages/vscode-ext-react-webview/src/webview-client/WebviewContext.tsx b/packages/vscode-ext-react-webview/src/webview-client/WebviewContext.tsx deleted file mode 100644 index 112d57aef..000000000 --- a/packages/vscode-ext-react-webview/src/webview-client/WebviewContext.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as React from 'react'; -import { createContext } from 'react'; -import { type WebviewApi } from 'vscode-webview'; - -export type WebviewState = object; - -export type WebviewContextValue = { - vscodeApi: WebviewApi<WebviewState>; -}; - -export const WebviewContext = createContext<WebviewContextValue>({} as WebviewContextValue); - -export const WithWebviewContext = ({ - vscodeApi, - children, -}: { - vscodeApi: WebviewApi<WebviewState>; - children: React.ReactNode; -}) => { - return <WebviewContext.Provider value={{ vscodeApi }}>{children}</WebviewContext.Provider>; -}; diff --git a/packages/vscode-ext-react-webview/src/webview-client/errorLink.ts b/packages/vscode-ext-react-webview/src/webview-client/errorLink.ts deleted file mode 100644 index 823ef993d..000000000 --- a/packages/vscode-ext-react-webview/src/webview-client/errorLink.ts +++ /dev/null @@ -1,88 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * A tRPC link that observes errors from queries and mutations and forwards - * them to a consumer-supplied handler before re-emitting them down the link - * chain. - * - * Why this exists. The default tRPC error flow propagates the error to the - * caller of `.query()` / `.mutation()`, where each call site is responsible - * for handling it. That works, but it forces every call site to remember to - * add a `.catch(...)` (or `try/catch` around `await`). When a consumer has - * a single place where webview-side errors should surface (an ARIA - * `Announcer`, a FluentUI `Toaster`, telemetry, etc.), this link is the - * place to plug that handler in once. - * - * Important semantics: - * - `onError` is invoked **in addition to** the normal tRPC error flow. - * The error is re-emitted on the observable so call-site `.catch(...)` - * handlers still fire. - * - Subscription errors are **not** forwarded to `onError`. Subscriptions - * have their own per-call `onError` callback on `.subscribe(...)` which - * gives the call site enough control without this link's help. Mixing - * the two would surface subscription errors twice. - */ - -import { type TRPCClientError, type TRPCLink } from '@trpc/client'; -import { type AnyRouter } from '@trpc/server'; -// eslint-disable-next-line import/no-internal-modules -- tRPC's own link examples import from /server/observable: https://trpc.io/docs/client/links#example -import { observable } from '@trpc/server/observable'; - -/** - * Callback invoked by {@link errorLink} for each query/mutation that errors - * out. The error is the same value the link is about to re-emit to the - * caller, normalized to an `Error` instance. - */ -export type ErrorHandler = (error: Error) => void; - -/** - * tRPC link factory. Use this in the `links` array passed to - * `createTRPCClient`, before {@link vscodeLink}: - * - * @example - * ```ts - * import { createTRPCClient, loggerLink } from '@trpc/client'; - * import { errorLink, vscodeLink } from '@microsoft/vscode-ext-react-webview'; - * - * const trpcClient = createTRPCClient<AppRouter>({ - * links: [ - * loggerLink(), - * errorLink<AppRouter>((err) => announcer.announceError(err.message)), - * vscodeLink<AppRouter>({ send, onReceive }), - * ], - * }); - * ``` - * - * Or pass an `onError` callback to {@link useTrpcClient}, which inserts the - * link for you. - */ -export function errorLink<TRouter extends AnyRouter>(onError: ErrorHandler): TRPCLink<TRouter> { - return () => { - return ({ next, op }) => { - return observable((observer) => { - return next(op).subscribe({ - next(value) { - observer.next(value); - }, - error(err: unknown) { - // Subscriptions handle their own errors via the - // per-call `.subscribe({ onError })` callback. Only - // intercept queries and mutations here so we do not - // surface subscription errors twice. - if (op.type !== 'subscription') { - const error = err instanceof Error ? err : new Error(String(err)); - onError(error); - } - observer.error(err as TRPCClientError<TRouter>); - }, - complete() { - observer.complete(); - }, - }); - }); - }; - }; -} diff --git a/packages/vscode-ext-react-webview/src/webview-client/index.ts b/packages/vscode-ext-react-webview/src/webview-client/index.ts deleted file mode 100644 index 354aa1fba..000000000 --- a/packages/vscode-ext-react-webview/src/webview-client/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export { errorLink, type ErrorHandler } from './errorLink'; -export { useConfiguration } from './useConfiguration'; -export { useTrpcClient, type TrpcClient, type UseTrpcClientOptions } from './useTrpcClient'; -export { - vscodeLink, - type VSCodeLinkOptions, - type VsCodeLinkRequestMessage, - type VsCodeLinkResponseMessage, -} from './vscodeLink'; -export { WebviewContext, WithWebviewContext, type WebviewContextValue, type WebviewState } from './WebviewContext'; diff --git a/packages/vscode-ext-react-webview/src/webview-client/useTrpcClient.ts b/packages/vscode-ext-react-webview/src/webview-client/useTrpcClient.ts deleted file mode 100644 index 888d13f96..000000000 --- a/packages/vscode-ext-react-webview/src/webview-client/useTrpcClient.ts +++ /dev/null @@ -1,149 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createTRPCClient, loggerLink, type CreateTRPCClient } from '@trpc/client'; -import { type AnyRouter } from '@trpc/server'; -import { useContext, useMemo } from 'react'; -import { errorLink, type ErrorHandler } from './errorLink'; -import { vscodeLink, type VsCodeLinkRequestMessage, type VsCodeLinkResponseMessage } from './vscodeLink'; -import { WebviewContext } from './WebviewContext'; - -/** - * Convenience alias for a fully-typed tRPC client for a given application router. - * - * @example - * ```ts - * import type { AppRouter } from './appRouter'; - * import type { TrpcClient } from '@microsoft/vscode-ext-react-webview'; - * - * type AppTrpcClient = TrpcClient<AppRouter>; - * ``` - */ -export type TrpcClient<TRouter extends AnyRouter> = CreateTRPCClient<TRouter>; - -/** - * Options accepted by {@link useTrpcClient}. - */ -export interface UseTrpcClientOptions { - /** - * Optional callback invoked for **every** query and mutation that - * errors out, in addition to the normal tRPC error flow (call-site - * `.catch(...)` handlers still fire). - * - * Subscription errors are intentionally not forwarded here. Use the - * per-subscription `.subscribe({ onError })` callback instead so that - * subscription failures do not surface twice. - * - * Typical uses: announce errors through an ARIA live region, show a - * toast, log to a webview-side telemetry sink. - * - * The callback is captured once when the hook builds its tRPC client. - * Pass a stable reference (e.g. `useCallback`) or accept that changing - * the callback will recreate the client on the next render. - */ - onError?: ErrorHandler; -} - -/** - * Custom React hook that creates a tRPC client for communication between the - * webview and the VS Code extension host. - * - * Each component that calls this hook receives its own client instance, stable - * across re-renders (via `useMemo`). This keeps components self-contained and - * easy to reason about. For views with many components (>10) consider sharing - * a single client via React context instead - see the "Advanced" section of - * the package README. - * - * @template TRouter - The application's root tRPC router type. - * @param options - Optional configuration. Pass `onError` to install a - * webview-side error observer for queries and mutations - * (see {@link UseTrpcClientOptions.onError}). - * @returns An object containing the tRPC client (`trpcClient`). - * - * @example - * ```tsx - * import { useTrpcClient } from '@microsoft/vscode-ext-react-webview'; - * import type { AppRouter } from '../_integration/appRouter'; - * - * export const MyComponent = () => { - * const { trpcClient } = useTrpcClient<AppRouter>({ - * onError: (err) => announcer.announceError(err.message), - * }); - * - * useEffect(() => { - * trpcClient.myProcedure.query().then((result) => { - * console.log('Procedure result:', result); - * }); - * }, [trpcClient]); - * - * return <></>; - * }; - * ``` - */ -export function useTrpcClient<TRouter extends AnyRouter>( - options?: UseTrpcClientOptions, -): { trpcClient: TrpcClient<TRouter> } { - const { vscodeApi } = useContext(WebviewContext); - const onError = options?.onError; - - /** - * Function to send messages to the VSCode extension. - * - * @param message - The message to send, following the VsCodeLinkRequestMessage format. - */ - function send(message: VsCodeLinkRequestMessage) { - vscodeApi.postMessage(message); - } - - /** - * Function to handle incoming messages from the VSCode extension. - * This function is provided to the tRPC client and is used internally to manage tRPC responses. - * - * @param callback - The callback to invoke when a tRPC response message is received. - * @returns A function to unsubscribe the event listener. - * - * Note to code maintainers: - * The tRPC client expects this `onReceive` function to handle the subscription and unsubscription - * of the event listener for tRPC responses. It registers the handler when a tRPC request is made, - * and unregisters it after the response is received. - * Be cautious when modifying this function, as it could affect the tRPC client's ability to - * receive responses correctly. - */ - function onReceive(callback: (message: VsCodeLinkResponseMessage) => void): () => void { - const handler = (event: MessageEvent) => { - // a basic type guard here - if ((event.data as VsCodeLinkResponseMessage).id) { - const message = event.data as VsCodeLinkResponseMessage; - callback(message); - } - }; - - window.addEventListener('message', handler); - return () => { - window.removeEventListener('message', handler); - }; - } - - // Each component that calls `useTrpcClient()` gets its own client instance. - // `useMemo` keeps the instance stable across re-renders of that component, - // but different components will hold separate clients. This is intentional: - // it keeps every component self-contained and easy to understand. - // For views with many components (>10) sharing a client via React context - // may be preferable - see the "Advanced" section of the README. - const trpcClient = useMemo( - () => - createTRPCClient<TRouter>({ - links: [ - loggerLink(), - ...(onError ? [errorLink<TRouter>(onError)] : []), - vscodeLink<TRouter>({ send, onReceive }), - ], - }), - [vscodeApi, onError], - ); - - // Return the tRPC client - return { trpcClient }; -} diff --git a/packages/vscode-ext-webview/ADVANCED.md b/packages/vscode-ext-webview/ADVANCED.md new file mode 100644 index 000000000..941498ead --- /dev/null +++ b/packages/vscode-ext-webview/ADVANCED.md @@ -0,0 +1,510 @@ +# ADVANCED.md - the behind-the-scenes manual + +This is the deep documentation behind the +[README](./README.md) quick start. The README shows the one-call front door +(`openWebview`); this file documents the primitives underneath it and the +patterns you reach for when you outgrow the front door. + +Everything here references real, shipped symbols. The public surface is split +across four entry points; see [Tiers](#tiers-and-when-to-use-each) below. + +## Table of contents + +- [Tiers and when to use each](#tiers-and-when-to-use-each) +- [Bring your own panel: `attachTrpc`](#bring-your-own-panel-attachtrpc) +- [Your own tRPC instance and `createCallerFactory`](#your-own-trpc-instance-and-createcallerfactory) +- [Create-or-reveal (single-instance panels)](#create-or-reveal-single-instance-panels) +- [Telemetry adapters](#telemetry-adapters) +- [The webview event channel](#the-webview-event-channel) +- [Framework-agnostic client: `connectTrpc`](#framework-agnostic-client-connecttrpc) +- [Push events from host to webview: `TypedEventSink`](#push-events-from-host-to-webview-typedeventsink) +- [The type-only router import rule](#the-type-only-router-import-rule) +- [FAQ](#faq) + +## Tiers and when to use each + +The package is three tiers stacked on a shared base. Pick the lowest tier that +solves your problem; each lower tier is more flexible and more verbose. + +| Tier | Subpath | Use it when | +| ------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Front door | `openWebview` (`./host`) + `./react` hooks | You want a panel, a router, and React hooks with the least ceremony. This is the README path. | +| Panel primitive | `WebviewController` / `attachTrpc` (`./host`) | You own the `vscode.WebviewPanel` lifecycle, or you need controller subclass hooks. | +| Transport primitive | `connectTrpc` / `vscodeLink` (`./webview`) | You use a UI framework other than React, or you need a bespoke client with custom links. | + +The shared `.` entry (router builders, `TypedEventSink`, wire types) sits under +all three and imports neither `vscode` nor React. + +## Bring your own panel: `attachTrpc` + +`WebviewController` is built on a single primitive: `attachTrpc`. When you +already own a `vscode.WebviewPanel` (because another part of your extension +created it, or you need custom panel options), wire tRPC onto it directly. + +```ts +import { attachTrpc } from '@microsoft/vscode-ext-webview/host'; +import { appRouter, trpc } from './webviews/_integration/appRouter'; + +const { disposable, activeOperations, activeSubscriptions } = attachTrpc( + panel, // your vscode.WebviewPanel + { workspaceRoot: '/path' }, // the router context + appRouter, + trpc.createCallerFactory, // from your initWebviewTrpc(...) result; optional + consoleProcedureLogger, // optional dispatch logger; omit for none +); + +panel.onDidDispose(() => disposable.dispose()); +``` + +`attachTrpc` returns: + +- `disposable` - tears down the message listener; dispose it on panel disposal. +- `activeOperations` - a `Map` of in-flight query / mutation `AbortController`s. +- `activeSubscriptions` - a `Map` of open subscriptions, keyed by id. + +It dispatches incoming webview messages (queries, mutations, subscriptions) +against the router, threads an `AbortSignal` into `ctx.signal`, and handles +`subscription.stop` and `abort` cancellation. If you pass a `ProcedureLogger`, +it logs one structured entry per completed call (see +[Telemetry adapters](#telemetry-adapters)). + +## Your own tRPC instance and `createCallerFactory` + +`initWebviewTrpc<TContext>()` returns a tRPC instance bound to your context +type. Destructure what you need: + +```ts +const { router, publicProcedure, createCallerFactory, middleware } = initWebviewTrpc<RouterContext>(); +``` + +- `router` builds (sub)routers. +- `publicProcedure` is the base procedure; its `ctx` is typed as `TContext`. +- `createCallerFactory` builds a server-side caller for a router. The host + dispatcher needs it to invoke procedures with full type inference. +- `middleware` builds reusable middleware bound to this instance (used by the + telemetry adapters below). + +**With `openWebview` / `WebviewController` (recommended): pass the whole +instance.** Export your `initWebviewTrpc<RouterContext>()` result as `trpc` and +hand it to the `trpc` option; the dispatcher reads `trpc.createCallerFactory` +off it. Because the factory travels with the instance that built your router it +can never be mismatched, and there is no separate `createCallerFactory` to +re-export: + +```ts +export const trpc = initWebviewTrpc<RouterContext>(); +export const appRouter = trpc.router({ /* … */ }); + +openWebview(ctx, { router: appRouter, trpc, context, config, sourceLayout }); +``` + +**With `attachTrpc` (bring-your-own-panel): pass the factory explicitly.** The +low-level primitive takes `createCallerFactory` as its (optional) fourth +argument, as shown above, so embedders that construct tRPC their own way stay in +full control. + +If you provide neither, the host falls back to the package's shared default +instance, which works only when your router is built with the package's default +`router` / `publicProcedure` (the ones exported from `.`). The old pattern of +re-exporting and passing a standalone `createCallerFactory` to `openWebview` is +deprecated in favour of the `trpc` option, but still honored. + +## Create-or-reveal (single-instance panels) + +`openWebview` creates a **new** panel on every call, so opening the same logical +view twice yields two tabs. Many extensions instead want *create-or-reveal*: the +first call opens the panel, later calls for the same key just bring the existing +tab to the foreground. The package deliberately does **not** own this — a panel +registry is consumer state, not transport state — but the returned controller +handle gives you everything needed to implement it in a few lines. + +Keep a `Map` from your own key to the live controller, reveal on a hit, and clear +the entry when the panel is disposed: + +```ts +import { openWebview, type WebviewController } from '@microsoft/vscode-ext-webview/host'; + +const openPanels = new Map<string, WebviewController<AppRouter, MyConfig, RouterContext>>(); + +function openOrReveal(ctx: vscode.ExtensionContext, key: string, config: MyConfig) { + const existing = openPanels.get(key); + if (existing && !existing.isDisposed) { + existing.revealToForeground(); // already open — just focus it + return existing; + } + + const controller = openWebview<AppRouter, MyConfig, RouterContext>(ctx, { + title: `My View — ${key}`, + viewType: 'myView', + router: appRouter, + trpc, + context: { + /* … */ + }, + config, + sourceLayout, + }); + + openPanels.set(key, controller); + controller.onDisposed(() => openPanels.delete(key)); // evict so a later call re-creates + return controller; +} +``` + +The key is yours to choose: a document id, a connection id, or `viewType` alone +for a true singleton. `revealToForeground()` and `onDisposed()` are the only +handle members this needs, and evicting on dispose keeps the map from leaking or +revealing a disposed controller. If several consumers converge on exactly this, +it becomes a candidate to promote into the package later; until then, keeping it +in consumer code keeps the package lean. + +## Telemetry adapters + +The package separates the telemetry policy (where data goes) from the +plumbing (how a call is timed and classified). Two middleware bodies cover the +two common policies; both are pure functions you wire onto your own procedure. + +### Console logging: `loggingMiddlewareBody` + `ProcedureLogger` + +`consoleProcedureLogger` is the zero-config default the panel uses out of the +box (it logs `[tRPC] <type> <path> (<ms>) <status>`). To route those entries +elsewhere, implement `ProcedureLogger`: + +```ts +import { loggingMiddlewareBody, type ProcedureLogger } from '@microsoft/vscode-ext-webview/host'; + +const logger: ProcedureLogger = { + log(entry) { + // entry: { type, path, durationMs, ok, aborted, error? } + myOutputChannel.appendLine(`${entry.type} ${entry.path} ${entry.durationMs}ms`); + }, +}; + +const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger)); +``` + +You can also pass a `ProcedureLogger` as the `telemetry` option to `openWebview` +/ `WebviewController` (or the last argument to `attachTrpc`) to log at the +dispatch layer without touching procedure definitions. + +### Analytics: `telemetryMiddlewareBody` + `TelemetryRunner` + +For real analytics (for example Application Insights via +`@microsoft/vscode-azext-utils`), implement a `TelemetryRunner` that +establishes a telemetry scope and hands the body a telemetry bag: + +```ts +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { + initWebviewTrpc, + // shared entry +} from '@microsoft/vscode-ext-webview'; +import { telemetryMiddlewareBody, type TelemetryRunner } from '@microsoft/vscode-ext-webview/host'; + +const runner: TelemetryRunner = { + async run(invocation, execute) { + const result = await callWithTelemetryAndErrorHandling( + `myExt.rpc.${invocation.type}.${invocation.path}`, + async (context) => { + context.errorHandling.suppressDisplay = true; + return execute(context.telemetry); + }, + ); + if (!result) throw new Error(`No result for ${invocation.type} ${invocation.path}`); + return result; + }, +}; + +const { publicProcedure } = initWebviewTrpc<RouterContext>(); +export const trackedProcedure = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); +``` + +`telemetryMiddlewareBody` injects the telemetry bag into `ctx.telemetry`, times +the call, records cancellation as `Canceled` (with `aborted: 'true'`), records +failures as `Failed` with the error name and message, and returns the +procedure's result unchanged. Build your router from `trackedProcedure` instead +of the bare `publicProcedure` to instrument every call. + +The dispatch logger and the middleware body are independent sinks: the logger +reports at the transport boundary, the middleware reports inside each procedure +scope. Using both does not double-count a single call into one analytics event. + +### Reading `ctx.telemetry` without casts: `WithTelemetry` + +`telemetryMiddlewareBody` injects the bag your `TelemetryRunner` supplies into +`ctx.telemetry`. The package types that slot minimally (`{ properties, +measurements }`) so it stays library-agnostic, but your runner usually hands over +a richer type — for example `ITelemetryContext` from +`@microsoft/vscode-azext-utils`. Rather than casting `ctx.telemetry` at every +procedure, re-type the context once with the exported `WithTelemetry` helper: + +```ts +import type { ITelemetryContext } from '@microsoft/vscode-azext-utils'; +import type { BaseRouterContext } from '@microsoft/vscode-ext-webview'; +import { type WithTelemetry } from '@microsoft/vscode-ext-webview/host'; + +type RouterContext = BaseRouterContext & { db: Db }; + +// The context as procedures see it once the telemetry middleware has run: +export type TrackedContext = WithTelemetry<RouterContext, ITelemetryContext>; + +export const stats = trackedProcedure.query(({ ctx }: { ctx: TrackedContext }) => { + ctx.telemetry.properties.result = 'ok'; // typed + ctx.telemetry.suppressIfSuccessful = true; // azext-specific field, also typed + return ctx.db.stats(); +}); +``` + +`WithTelemetry<TContext, TTelemetry>` is `Omit<TContext, 'telemetry'> & +{ telemetry: TTelemetry }`: it swaps the minimal slot for your concrete type and +makes it required. Consumers commonly alias it once so procedure code just writes +`WithTelemetry<Ctx>`: + +```ts +import { type WithTelemetry as FrameworkWithTelemetry } from '@microsoft/vscode-ext-webview/host'; +export type WithTelemetry<T extends { telemetry?: unknown }> = FrameworkWithTelemetry<T, ITelemetryContext>; +``` + +## The webview event channel + +By default a query or mutation that throws on the host propagates to the +call-site `.catch(...)` (or `try / catch` around `await`). That works, but it +forces every call site to remember to handle the error. When a single place +should always see webview-side outcomes (an ARIA announcer, a toaster, +telemetry), observe the event channel. + +`useRpcEvents()` returns the per-webview `RpcEventChannel`: + +```tsx +import { useEffect } from 'react'; +import { useRpcEvents } from '@microsoft/vscode-ext-webview/react'; + +function ErrorAnnouncer() { + const events = useRpcEvents(); + useEffect(() => { + const off = events.onError((err, info) => announcer.announceError(`${info.path}: ${err.message}`)); + return off; + }, [events]); + return null; +} +``` + +The channel exposes three observe methods, each returning an unsubscribe: + +- `onSuccess(handler)` - the call resolved; `handler(info, data)`. +- `onError(handler)` - the call rejected; `handler(error, info)`. +- `onAborted(handler)` - the call was canceled; `handler(info)`. + +`info` is a `CallInfo` (`{ type, path }`). The channel observes; it does not +swallow. Your call-site handlers still run and still receive the error. +**Subscriptions are intentionally _not_ published to the channel** — observe a +subscription's outcome through its own `.subscribe({ onError, onComplete })` +callbacks. Only query and mutation outcomes flow through `onSuccess` / `onError` +/ `onAborted`, so a subscription's events are never surfaced twice. + +### When an observer throws: `onObserverError` + +The channel **isolates** a throwing observer: if one of your `onSuccess` / +`onError` / `onAborted` handlers throws, the channel catches it so the throw +cannot break the tRPC call the handler was only observing (the observer-only +contract). The isolated error is routed to an `onObserverError` sink that +defaults to `console.error`. Pass your own via `connectTrpc(vscodeApi, { +onObserverError })` — or the `onObserverError` prop of `WithWebviewContext` — to +route observer failures to telemetry: + +```ts +const { client, events } = connectTrpc<AppRouter>(vscodeApi, { + onObserverError: (error, { info, phase }) => { + // Structured: you know exactly which call and phase produced the throw. + reportEvent('webview.observerError', { path: info.path, phase, message: String(error) }); + }, +}); +``` + +`phase` is `'success' | 'error' | 'aborted'` and `info` is the same `CallInfo` +the observers receive. A throw from the sink itself is also swallowed — nothing +an observer (or its error sink) does can affect dispatch. The default is +deliberately quiet (`console.error`, no telemetry) so a generic consumer is not +opted into events it may not want; wire the sink when you want observer failures +in your telemetry. + +The channel and the tRPC client are created together and shared per webview, so +`useTrpcClient()` and `useRpcEvents()` always see the same instance. + +## Framework-agnostic client: `connectTrpc` + +The React hooks are sugar over a React-free factory. `connectTrpc(vscodeApi, +options?)` from `./webview` builds the same client and event channel with no +React dependency, so you can bind another UI framework on top of the transport: + +```ts +import { connectTrpc } from '@microsoft/vscode-ext-webview/webview'; +import type { AppRouter } from './_integration/appRouter'; + +const vscodeApi = acquireVsCodeApi(); +const { client, events } = connectTrpc<AppRouter>(vscodeApi, { + onError: (err) => console.error(err), +}); + +const result = await client.hello.query({ name: 'world' }); +events.onAborted((info) => console.debug('canceled', info.path)); +``` + +`vscodeApi` only needs a `postMessage(message)` method (`VsCodeApiLike`). The +`onError` option is a shorthand for `events.onError((err) => onError(err))`; +aborted calls are reported via `onAborted`, not `onError`. For full control over +link order (for example composing a third-party logging link), import +`vscodeLink` (and optionally `errorLink` / the lower-level `eventLink`) and +build the client yourself with `createTRPCClient`. + +## Push events from host to webview: `TypedEventSink` + +tRPC subscriptions are `async function*` generators, which fit pull-style +producers (cursors, polling loops). For push-style producers (VS Code event +emitters, driver callbacks, completion notifiers) you need an adapter that the +producer can call imperatively and the subscription can iterate. + +`TypedEventSink<T>` is that adapter. It implements `AsyncIterable<T>` over a +discriminated event union, with a write-only `emit(event)` (or `emit(type, +payload)`) on the producer side and `for await (const event of sink)` on the +consumer side. Single consumer per sink; events emitted before a consumer +attaches are buffered. + +Define the event union and the events router: + +```ts +// _integration/myViewEventsRouter.ts +import { initWebviewTrpc, type BaseRouterContext, type TypedEventSink } from '@microsoft/vscode-ext-webview'; + +export type MyViewEvent = { type: 'progress'; percent: number } | { type: 'completed'; durationMs: number }; + +type MyViewRouterContext = BaseRouterContext & { + eventSink: TypedEventSink<MyViewEvent>; +}; + +const { router, publicProcedure } = initWebviewTrpc<MyViewRouterContext>(); + +export const myViewEventsRouter = router({ + events: publicProcedure.subscription(async function* ({ ctx }) { + for await (const event of ctx.eventSink) { + if (ctx.signal?.aborted) return; + yield event; + } + }), +}); +``` + +Emit from anywhere in host code that holds the sink: + +```ts +sink.emit({ type: 'progress', percent: 25 }); +sink.emit('completed', { durationMs: 1500 }); +``` + +Consume in the webview with the standard subscription API: + +```tsx +useEffect(() => { + const sub = trpcClient.myView.events.subscribe(undefined, { + onData: (event) => { + if (event.type === 'progress') setPercent(event.percent); + else if (event.type === 'completed') setDoneAfter(event.durationMs); + }, + }); + return () => sub.unsubscribe(); +}, [trpcClient]); +``` + +Recommended convention: put push-event procedures in a sibling +`<view>EventsRouter.ts` and merge it into the view's main router. This keeps +"things the webview calls" and "things the host pushes" in separate files, so +the entire event vocabulary of a view is discoverable at a glance. + +When the producer (panel, session, task) finishes for good, call `sink.close()`. +The framework already cleans up async iteration on unsubscribe and panel +disposal via `iterator.return()`, but `close()` is still the right signal +whenever the sink has no more events to ever emit: it lets late producers stop +without checking, and prevents reuse by accident. + +## The type-only router import rule + +The webview needs the `AppRouter` type to keep `useTrpcClient<AppRouter>()` +type-safe end to end. This is the only thing webview code should import from the +file that defines your router. + +```tsx +import type { AppRouter } from '../_integration/appRouter'; +import { useTrpcClient } from '@microsoft/vscode-ext-webview/react'; + +const trpcClient = useTrpcClient<AppRouter>(); +``` + +A few rules of thumb keep the host / browser boundary honest: + +- Use `import type { AppRouter } from '...'` (or `import { type AppRouter }`). + Type-only imports are erased at compile time and never produce runtime + references, so the webview bundle stays free of extension-host code even if + the router module also imports Node-only APIs. +- Do not import runtime values (the `appRouter` constant, procedure builders, + middleware) from webview code. Those belong to the extension-host side. +- Do not reach into procedure implementation files from the webview side. + Router types are the only contract the webview consumes. + +If your bundler ever pulls host modules into the views bundle, the most common +cause is a non-type-only import of `AppRouter`. Switching it to +`import type { ... }` resolves it without changes to the router itself. + +## FAQ + +### Why tRPC instead of raw `postMessage`? + +Raw `postMessage` requires you to define message types manually, match +request / response pairs by hand, and serialise yourself. tRPC threads +TypeScript types end to end. Your host procedures and webview calls share the +same `AppRouter` type with zero code generation. Renaming a field on the host +shows a compile error in the webview immediately. + +### Why are there four entry points? + +`.` is side-agnostic (no `vscode`, no React). `./host` is the extension-host +surface and imports `fs` / `path` / `vscode`. `./webview` is the browser-side +transport with no React dependency. `./react` adds the React hooks. Splitting +them keeps Node / `vscode` out of the webview bundle, and keeps React out of a +non-React consumer's bundle. + +### Can I have multiple webview panels open at the same time? + +Yes. Each `WebviewController` (or `attachTrpc` call) owns its own panel and its +own tRPC caller. State is not shared between panels unless you explicitly +coordinate through the host (for example a singleton service). + +### Can I cancel a long-running query or subscription? + +Yes. Pass an `AbortSignal` on the client side: + +```ts +const ac = new AbortController(); +const result = await trpcClient.something.query(input, { signal: ac.signal }); +// later +ac.abort(); +``` + +On the host side, read `ctx.signal` inside your procedure to cooperatively stop +work. Subscriptions stop cleanly when the client unsubscribes: the framework +sends a `subscription.stop` message and the dispatcher both aborts the +per-operation `AbortController` and calls `iterator.return()` on the procedure's +async generator. The `return()` call propagates into any inner `for await` loop, +including loops over a `TypedEventSink`, releasing consumers parked on the next +event without waiting for the producer to emit or close. The same cleanup runs +when the panel is disposed. + +### Can I use a UI library other than React? + +Yes, on top of `./webview`. The React hooks (`useTrpcClient`, `useRpcEvents`, +`useConfiguration`, `WithWebviewContext`) are React-specific, but the underlying +transport (`connectTrpc`, `vscodeLink`) is framework-agnostic. Build a binding +for your framework on top of `connectTrpc`; the package itself does not ship one. + +## License + +MIT. See [LICENSE.md](../../LICENSE.md) at the repository root. diff --git a/packages/vscode-ext-webview/LICENSE b/packages/vscode-ext-webview/LICENSE new file mode 100644 index 000000000..88040d887 --- /dev/null +++ b/packages/vscode-ext-webview/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vscode-ext-webview/README.md b/packages/vscode-ext-webview/README.md new file mode 100644 index 000000000..ade066934 --- /dev/null +++ b/packages/vscode-ext-webview/README.md @@ -0,0 +1,463 @@ +# @microsoft/vscode-ext-webview (Preview) + +> **Preview release.** This package is published in preview while the API +> surface stabilises. Breaking changes may land between minor versions until +> a `1.0.0` release. + +Webview infrastructure for VS Code extensions: type-safe tRPC over +`postMessage`, a one-call front door that opens a panel and wires the +transport, optional React hooks for the webview side, and pluggable telemetry. + +The transport was extracted from the webview stack that ships in the +battle-tested [DocumentDB for VS Code](https://github.com/microsoft/vscode-documentdb) +and [Azure Cosmos DB for VS Code](https://github.com/microsoft/vscode-cosmosdb) +extensions, where it powers the production collection, document, and query +experiences that people use every day. + +A companion [vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) +repository was built to make onboarding easy: it is a ready-to-run reference you +can use as a template for a brand-new extension, or read alongside this package +to see every moving part wired together. + +--- + +## Crossing the webview boundary + +A VS Code webview runs in its own isolated context: a sandboxed iframe with no +direct access to the extension host, the file system, or the VS Code API. The +only way in or out is asynchronous `postMessage`. That isolation is good for +security, but it means every feature has to cross a process-like boundary by +hand. You serialize a message, correlate responses with requests, track +cancellation, and keep both sides' types in sync as the code changes. + +This package turns that boundary into a typed function call. You define a tRPC +router once on the extension host, and the webview calls it like a local API +with full type inference, autocompletion, and refactor-safety. The transport, +request and response correlation, cancellation, and lifecycle wiring are handled +for you, so feature code never touches `postMessage` directly. + +## Architecture + +The extension host owns a `vscode.WebviewPanel`; the webview runs your React +app inside it and talks to the host through tRPC over `window.postMessage`. +There is no HTTP, no WebSocket, and no string-typed protocol to maintain by +hand. tRPC types flow from the router definition straight to the React +component that calls into it. + +``` +Extension Host (Node.js) Webview (Browser) ++----------------------------+ +----------------------------+ +| openWebview / Controller | | React tree | +| |- router (tRPC) | <-----> | |- WithWebviewContext | +| |- attachTrpc dispatch | post | |- useTrpcClient | +| |- telemetry logger | Msg | |- useConfiguration | +| '- AbortSignal in ctx | | '- vscodeLink transport | ++----------------------------+ +----------------------------+ + import from ./host import from ./react | ./webview +``` + +The same `AppRouter` type is shared by both sides: define the router once on +the extension host, then call it from the webview with full type inference, +auto-completion, and refactor-safety. + +## Quick start + +The shortest path to a working webview is four files: define a router, open +the panel with `openWebview`, render the React tree with `WithWebviewContext`, +and call procedures with `useTrpcClient`. + +> For a complete extension layout with build configuration, accessibility +> helpers, Monaco wiring, and tested-end-to-end command registration, copy the +> [vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) +> instead of starting from these snippets. The starter kit is the canonical +> consumer reference; this section is for understanding the moving parts. + +**1. Install** + +```bash +npm install @microsoft/vscode-ext-webview +``` + +The package declares `react`, `@trpc/client`, and `@trpc/server` as peer +dependencies. Bring whatever versions you use yourself; the package will not +pull duplicates into your webview bundle. `react-dom` is not a peer of this +package; it is a transitive concern of any React DOM app shell. + +**2. Define the router (extension host)** + +```ts +// src/webviews/_integration/appRouter.ts +import { initWebviewTrpc, type BaseRouterContext } from '@microsoft/vscode-ext-webview'; +import { z } from 'zod'; + +export type RouterContext = BaseRouterContext & { + // application-specific fields, e.g.: + workspaceRoot: string; +}; + +export const trpc = initWebviewTrpc<RouterContext>(); + +export const appRouter = trpc.router({ + hello: trpc.publicProcedure + .input(z.object({ name: z.string() })) + .query(({ input }) => ({ greeting: `Hello, ${input.name}!` })), +}); + +export type AppRouter = typeof appRouter; +``` + +`initWebviewTrpc<TContext>()` returns the tRPC builders bound to your context +type: `router`, `publicProcedure` (whose `ctx` is typed as `TContext`), +`createCallerFactory` (used by the host dispatcher), and `middleware`. Export the +whole instance as `trpc` and pass it to `openWebview` (below): the dispatcher +reads `trpc.createCallerFactory` off it, so the caller factory can never be +mismatched with your router and there is nothing extra to re-export. + +**3. Open the panel (extension host)** + +```ts +// src/extension.ts +import * as vscode from 'vscode'; +import { openWebview } from '@microsoft/vscode-ext-webview/host'; +import { appRouter, trpc, type AppRouter, type RouterContext } from './webviews/_integration/appRouter'; + +type MyViewConfig = { initialMessage: string }; + +export function activate(ctx: vscode.ExtensionContext) { + ctx.subscriptions.push( + vscode.commands.registerCommand('myExtension.openMyView', () => { + openWebview<AppRouter, MyViewConfig, RouterContext>(ctx, { + title: 'My View', + viewType: 'myView', // matches the React component registration + router: appRouter, + trpc, + context: { workspaceRoot: vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? '' }, + config: { initialMessage: 'ready' } satisfies MyViewConfig, + sourceLayout: { + bundled: { dir: '', file: 'views.js' }, + dev: { dir: 'out/src/webviews', file: 'index.js' }, + }, + isBundled: !!process.env.IS_BUNDLE, + devServerHost: 'http://localhost:18080', + }); + }), + ); +} +``` + +`openWebview` returns a `WebviewController` handle exposing `panel`, +`onDisposed`, `revealToForeground`, `dispose`, and `isDisposed`. It opens the +panel, renders the HTML, and wires the tRPC dispatch pump. Procedure activity +is logged to the extension-host console out of the box; pass a `logger` option +to route the entries elsewhere. See [Observability](#observability) for logging +and Application Insights-style telemetry. + +**4. Render the view (webview / browser)** + +The webview entry point exports a `render(viewType, vscodeApi)` function that +the framework's HTML scaffold calls when the panel loads. The `viewType` +argument is the key you passed to `openWebview` (here, `'myView'`); use it to +look up the matching React component. + +```tsx +// src/webviews/index.tsx +import { createRoot } from 'react-dom/client'; +import { WithWebviewContext, type WebviewState } from '@microsoft/vscode-ext-webview/react'; +import type { WebviewApi } from 'vscode-webview'; +import { MyView } from './myView/MyView'; + +const registry = { + myView: MyView, +} as const; + +export function render(viewType: keyof typeof registry, vscodeApi: WebviewApi<WebviewState>) { + const Component = registry[viewType]; + createRoot(document.getElementById('root')!).render( + <WithWebviewContext vscodeApi={vscodeApi}> + <Component /> + </WithWebviewContext>, + ); +} +``` + +```tsx +// src/webviews/myView/MyView.tsx +import { useEffect, useState } from 'react'; +import { useConfiguration, useTrpcClient } from '@microsoft/vscode-ext-webview/react'; +import type { AppRouter } from '../_integration/appRouter'; + +type MyViewConfig = { initialMessage: string }; + +export const MyView = () => { + const config = useConfiguration<MyViewConfig>(); + const trpcClient = useTrpcClient<AppRouter>(); + const [greeting, setGreeting] = useState(config.initialMessage); + + useEffect(() => { + void trpcClient.hello.query({ name: 'world' }).then((r) => setGreeting(r.greeting)); + }, [trpcClient]); + + return <h1>{greeting}</h1>; +}; +``` + +That is the complete data path: the React component calls +`trpcClient.hello.query(...)`, the call travels through `vscodeLink` as a +`postMessage`, the host dispatches it to `appRouter.hello`, the result is +`postMessage`d back, and the call promise resolves with full type inference for +`r.greeting`. + +`useTrpcClient<AppRouter>()` returns the client directly. The client (and the +event channel from `useRpcEvents()`) is shared per webview: every component that +calls the hook receives the same instance, so there is no provider tree to wire +up and cross-cutting observers see every call. + +## Behind the scenes (advanced, optional) + +The factory is the front door, but every layer underneath it is a public, +documented primitive. Reach for these when you outgrow the one-call path. Each +is covered in depth in [ADVANCED.md](./ADVANCED.md). + +- **Bring your own panel.** `attachTrpc(panel, ctx, router, callerFactory?, logger?)` + wires the tRPC dispatch pump onto a `vscode.WebviewPanel` you already own, + without `WebviewController`. +- **Framework-agnostic webview client.** `connectTrpc(vscodeApi, options?)` from + `./webview` builds a typed client plus an event channel with no React + dependency, so you can bind another UI framework on top of the transport. +- **Webview-side error and event observer.** `createEventChannel()` and the + `errorLink` tRPC link surface success, error, and aborted events to a single + place (an announcer, a toaster, telemetry). +- **Pluggable telemetry.** `telemetryMiddlewareBody` plus a `TelemetryRunner` + routes per-call telemetry into your own analytics (for example Application + Insights); `loggingMiddlewareBody` and `ProcedureLogger` cover console-style + logging. +- **Push events from host to webview.** `TypedEventSink<T>` bridges push-style + producers (VS Code event emitters, driver callbacks) into tRPC subscriptions. +- **The type-only router import rule.** Webview code imports only + `import type { AppRouter }`, keeping extension-host code out of the browser + bundle. + +See [ADVANCED.md](./ADVANCED.md) for the full manual, including a single shared +client, worked telemetry adapters, the event channel, push events, and the +host/browser import boundary. + +## Observability + +The transport reports what it is doing through **logging** (console / output, for +humans) and **telemetry** (structured analytics, for dashboards). Logging comes +in two flavours — one on the webview side, one on the extension-host side — and +telemetry is a separate, policy-driven layer. Everything except the host +dispatch logger's console default is opt-in, so you enable exactly what you need. + +### Webview-side request logging + +The shared webview client can log every query / mutation / subscription to the +**webview devtools console** using tRPC's `loggerLink` — a rich, grouped, +color-coded view of each call's input and result. It is **off by default** so a +production webview stays quiet. + +Enable it once, at the React root, via `WithWebviewContext`: + +```tsx +import { WithWebviewContext } from '@microsoft/vscode-ext-webview/react'; + +<WithWebviewContext vscodeApi={vscodeApi} enableRpcLogging> + <App /> +</WithWebviewContext>; +``` + +For the framework-agnostic client, pass the flag directly: + +```ts +const { client } = connectTrpc<AppRouter>(vscodeApi, { logger: true }); +``` + +**Opening the webview console from VS Code:** run **Developer: Open Webview +Developer Tools** from the Command Palette (with the webview focused) to open the +Chromium devtools for that webview; the tRPC log appears on the **Console** tab. +When you launch the extension with a debugger attached this is the webview's own +console — distinct from the extension-host Debug Console, where the dispatch +logger below writes. + +### Webview-side observer errors + +Cross-cutting observers on the event channel (`useRpcEvents()` / `connectTrpc`'s +`events`) are **isolated**: if one of your `onSuccess` / `onError` / `onAborted` +handlers throws, the channel catches it so a broken observer can never break the +RPC it was only watching. The isolated error goes to an `onObserverError` sink +that defaults to `console.error`. Route it to telemetry when you want observer +failures tracked — **off by default** so you are not opted into events you may +not want: + +```tsx +<WithWebviewContext + vscodeApi={vscodeApi} + onObserverError={(error, { info, phase }) => report(info.path, phase, error)} +> + <App /> +</WithWebviewContext> +``` + +See [ADVANCED.md](./ADVANCED.md#the-webview-event-channel) for the full contract. + +### Extension-host dispatch logging + +The host dispatcher logs one structured entry per completed query, mutation, and +subscription (`[tRPC] <type> <path> (<ms>) <status>`). It is on by default via +`consoleProcedureLogger`, writing to the **extension-host console** — the +`Extension Host` output channel, or your debugger's Debug Console when you run +the extension under a debugger. + +Route those entries elsewhere by passing your own `ProcedureLogger` as the +`logger` option. The option is named `logger`, not `telemetry`, because it is log +plumbing; for analytics see the next section. + +```ts +import { openWebview, type ProcedureLogger } from '@microsoft/vscode-ext-webview/host'; + +const channel = vscode.window.createOutputChannel('My View'); +const logger: ProcedureLogger = { + log: (e) => channel.appendLine(`${e.type} ${e.path} ${e.durationMs}ms ${e.ok ? 'ok' : 'FAIL'}`), +}; + +openWebview(ctx, { + /* …title, viewType, router, context, config, sourceLayout… */ + logger, +}); +``` + +### Telemetry (Application Insights via `@microsoft/vscode-azext-utils`) + +For structured analytics, wire the instance-agnostic `telemetryMiddlewareBody` +onto your procedures with a `TelemetryRunner` adapter. The runner establishes the +telemetry scope; the body times each call, records `Canceled` / `Failed`, and +injects a telemetry bag into `ctx.telemetry`. Most VS Code extensions route this +through `callWithTelemetryAndErrorHandling`: + +```ts +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { initWebviewTrpc } from '@microsoft/vscode-ext-webview'; +import { telemetryMiddlewareBody, type TelemetryRunner } from '@microsoft/vscode-ext-webview/host'; + +const runner: TelemetryRunner = { + async run(invocation, execute) { + const result = await callWithTelemetryAndErrorHandling( + `myExt.rpc.${invocation.type}.${invocation.path}`, + async (context) => { + context.errorHandling.suppressDisplay = true; + return execute(context.telemetry); // hands the azext telemetry bag to the body + }, + ); + if (!result) throw new Error(`no telemetry result for ${invocation.path}`); + return result; + }, +}; + +const { publicProcedure } = initWebviewTrpc<RouterContext>(); +// Build your router from `tracked` instead of the bare `publicProcedure`: +export const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); +``` + +Inside a procedure, read `ctx.telemetry` to add properties/measurements. The +package types that slot minimally, so azext consumers usually re-type it to +`ITelemetryContext` with a one-line `WithTelemetry<T>` helper — see +[ADVANCED.md](./ADVANCED.md#telemetry-adapters) for the full worked adapter and a +copyable helper. + +## Entry points + +The package has four entry points so bundlers do not drag Node / VS Code APIs +into the webview bundle, and so a non-React consumer never pulls React in. + +| Subpath | Side | Imports | Key exports | +| ----------- | -------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `.` | shared (side-agnostic) | no `vscode`, no React | `initWebviewTrpc`, `BaseRouterContext`, `TypedEventSink`, wire-protocol message types | +| `./host` | extension host (Node.js) | `fs`, `path`, `vscode` | `openWebview`, `WebviewController`, `attachTrpc`, `telemetryMiddlewareBody`, `loggingMiddlewareBody`, `consoleProcedureLogger` | +| `./webview` | webview (browser), any framework | no React | `connectTrpc`, `createEventChannel`, `vscodeLink`, `errorLink` | +| `./react` | webview (browser), React | React | `useTrpcClient`, `useRpcEvents`, `useConfiguration`, `WithWebviewContext` | + +```ts +// Shared. Safe to import from either side. +import { initWebviewTrpc, TypedEventSink, type BaseRouterContext } from '@microsoft/vscode-ext-webview'; + +// Extension host side. Uses fs, path, vscode. +import { openWebview, WebviewController, attachTrpc } from '@microsoft/vscode-ext-webview/host'; + +// Webview, framework-agnostic. No React. +import { connectTrpc, createEventChannel, vscodeLink } from '@microsoft/vscode-ext-webview/webview'; + +// Webview, React hooks. +import { useTrpcClient, useConfiguration, WithWebviewContext } from '@microsoft/vscode-ext-webview/react'; +``` + +## What's inside + +- **`openWebview` / `WebviewController`**: open a `vscode.WebviewPanel`, + dispatch incoming tRPC operations (queries, mutations, subscriptions), and + handle abort / subscription cancellation lifecycle. The factory is sugar over + the controller's options-bag constructor. +- **`attachTrpc`**: the dispatch primitive the controller is built on; wire tRPC + onto a panel you already own. +- **`initWebviewTrpc`**: the typed tRPC initialiser. Returns `router`, + `publicProcedure`, `createCallerFactory`, and `middleware` bound to your + context type. +- **`connectTrpc` / `createEventChannel`**: the framework-agnostic webview + client and its observable event channel. +- **`vscodeLink`**: a custom tRPC link that bridges tRPC over + `window.postMessage`, type-safe end to end. +- **`errorLink`**: an optional tRPC link that forwards query / mutation errors + to a consumer-supplied handler (announce, toast, telemetry) without preventing + the normal error flow. +- **`TypedEventSink<T>`**: a small typed async-iterable that bridges push-style + domain events into tRPC subscriptions. +- **React hooks**: `useTrpcClient`, `useRpcEvents`, `useConfiguration`, and the + `WithWebviewContext` provider. +- **Pluggable telemetry**: `telemetryMiddlewareBody` + `TelemetryRunner` and + `loggingMiddlewareBody` + `ProcedureLogger`, with `consoleProcedureLogger` as + the zero-config default. + +## Peer dependencies + +| Package | Required version | +| ---------------- | -------------------------------------- | +| `react` | `>=18.0.0` (only for `./react`) | +| `@trpc/client` | `^11.0.0` | +| `@trpc/server` | `^11.0.0` | +| `vscode-webview` | `^1.0.0` (optional, webview-side only) | + +## Scope + +This package ships only the webview transport (tRPC over `postMessage`), the +panel facade, and the minimum React glue to consume it. UI components, UX policy +(context-menu handling, focus management, and so on), accessibility helpers, +editor-specific behaviours, and other consumer concerns are out of scope by +design. Keep them in your application repository or pick dedicated libraries for +them. + +## Starter kit and reference consumers + +The recommended way to start a new consumer is to copy the +[vscode-webview-starter-kit](https://github.com/tnaum-ms/vscode-webview-starter-kit) +and adapt it. The starter kit covers things the package intentionally does not +own: webpack / Vite build configuration for both the extension and the views +bundle; accessibility helpers (an ARIA announcer, a selective context-menu +prevention hook); a Monaco editor integration recipe; and a worked demo view +exercising the tRPC client end to end. + +A consumer-side integration layer (router + telemetry runner + configuration +knobs) typically lives in a folder like `src/webviews/_integration/`. The +underscore prefix sorts the folder above feature folders in the file explorer, +the conventional "infrastructure / not feature code" signal. The +[vscode-documentdb](https://github.com/microsoft/vscode-documentdb) and +[Azure Cosmos DB for VS Code](https://github.com/microsoft/vscode-cosmosdb) +extensions are working examples of that layout against this package. + +## Status + +`0.9.0-preview`. APIs are subject to change while the package is in preview. See +[ADVANCED.md](./ADVANCED.md) for the full set of primitives and patterns. + +## License + +MIT. See [LICENSE](./LICENSE) (shipped with the package); the repository-wide +copy lives at the [repository root](../../LICENSE.md). diff --git a/packages/vscode-ext-webview/jest.config.js b/packages/vscode-ext-webview/jest.config.js new file mode 100644 index 000000000..f67bc5122 --- /dev/null +++ b/packages/vscode-ext-webview/jest.config.js @@ -0,0 +1,20 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + displayName: 'vscode-webview-api', + // Limit workers to avoid OOM kills on machines with many cores. + // Each ts-jest worker loads the TypeScript compiler and consumes ~500MB+. + maxWorkers: '50%', + testEnvironment: 'node', + testMatch: ['<rootDir>/src/**/*.test.ts'], + // The host facade (WebviewController / openWebview) imports `vscode` at + // runtime; map it to a minimal stub. Type-checking still uses @types/vscode. + // The stub deliberately lives outside any `__mocks__/` directory so it is + // not picked up as a global jest manual mock (which would collide with the + // extension's own vscode mock in the root multi-project run). + moduleNameMapper: { + '^vscode$': '<rootDir>/src/testing/vscodeStub.ts', + }, + transform: { + '^.+\\.tsx?$': ['ts-jest', {}], + }, +}; diff --git a/packages/vscode-ext-react-webview/package.json b/packages/vscode-ext-webview/package.json similarity index 50% rename from packages/vscode-ext-react-webview/package.json rename to packages/vscode-ext-webview/package.json index 44735781b..06db32a97 100644 --- a/packages/vscode-ext-react-webview/package.json +++ b/packages/vscode-ext-webview/package.json @@ -1,7 +1,7 @@ { - "name": "@microsoft/vscode-ext-react-webview", - "version": "0.8.0-preview", - "description": "Webview infrastructure for VS Code extensions with type-safe tRPC RPC over postMessage, React hooks, and pluggable telemetry middleware", + "name": "@microsoft/vscode-ext-webview", + "version": "0.9.0-preview", + "description": "Webview infrastructure for VS Code extensions: type-safe tRPC over postMessage with pluggable telemetry middleware, plus an optional extension-host panel facade, a framework-agnostic webview client, and optional React hooks.", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { @@ -9,21 +9,37 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./server": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" + "./host": { + "types": "./dist/host.d.ts", + "default": "./dist/host.js" + }, + "./webview": { + "types": "./dist/webview.d.ts", + "default": "./dist/webview.js" + }, + "./react": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" } }, "typesVersions": { "*": { - "server": [ - "./dist/server.d.ts" + "host": [ + "./dist/host.d.ts" + ], + "webview": [ + "./dist/webview.d.ts" + ], + "react": [ + "./dist/react.d.ts" ] } }, "files": [ "dist", - "README.md" + "README.md", + "ADVANCED.md", + "LICENSE" ], "scripts": { "build": "tsc -p .", @@ -34,7 +50,7 @@ "repository": { "type": "git", "url": "https://github.com/microsoft/vscode-documentdb", - "directory": "packages/vscode-ext-react-webview" + "directory": "packages/vscode-ext-webview" }, "license": "MIT", "publishConfig": { @@ -47,6 +63,9 @@ "vscode-webview": "^1.0.0" }, "peerDependenciesMeta": { + "react": { + "optional": true + }, "vscode-webview": { "optional": true } diff --git a/packages/vscode-ext-webview/src/README.md b/packages/vscode-ext-webview/src/README.md new file mode 100644 index 000000000..c4dd3baac --- /dev/null +++ b/packages/vscode-ext-webview/src/README.md @@ -0,0 +1,19 @@ +# src layout + +The package has four public entry points, one folder each. Every folder below +has its own README with the details. + +| Folder | Import path | Runs on | +| --------- | --------------------------------------- | --------------------------- | +| `shared` | `@microsoft/vscode-ext-webview` | host and webview (agnostic) | +| `host` | `@microsoft/vscode-ext-webview/host` | extension host (Node) | +| `webview` | `@microsoft/vscode-ext-webview/webview` | webview (browser) | +| `react` | `@microsoft/vscode-ext-webview/react` | webview (browser, React) | + +The `*.ts` files at this level (`index.ts`, `host.ts`, `webview.ts`, +`react.ts`) are entry barrels that re-export each folder; they map to the +subpaths declared in `package.json` `exports`. `testing/` holds shared test +helpers and ships no runtime code. + +Import direction: `react` builds on `webview`; `host` and `webview` both build +on `shared`; `shared` depends on nothing else in this package. diff --git a/packages/vscode-ext-webview/src/host.ts b/packages/vscode-ext-webview/src/host.ts new file mode 100644 index 000000000..f9f2546c5 --- /dev/null +++ b/packages/vscode-ext-webview/src/host.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Entry point for the extension-host surface of `@microsoft/vscode-ext-webview` + * (the `./host` subpath). + * + * Imported from the extension's Node.js code (controllers, routers, telemetry + * adapters). Pulls in Node / VS Code APIs (`fs`, `path`, `vscode`) and must not + * be bundled into the webview's browser-side code. + */ + +export * from './host/index'; diff --git a/packages/vscode-ext-webview/src/host/README.md b/packages/vscode-ext-webview/src/host/README.md new file mode 100644 index 000000000..72513c222 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/README.md @@ -0,0 +1,24 @@ +# host + +Extension-host code. Runs in the VS Code extension process (Node) and may import +`vscode`. It is never bundled into the webview. + +Import path: + +```ts +import { ... } from '@microsoft/vscode-ext-webview/host'; +``` + +Contents: + +- `openWebview.ts`: the factory. The simplest way to open a panel; returns a + `WebviewController`. +- `WebviewController.ts`: the class the factory returns. Extend it for stateful + or method-rich panels. +- `attachTrpc.ts`: bring-your-own-panel. Attaches the tRPC dispatcher to a + `vscode.WebviewPanel` you already created. +- `middleware/`: optional logging and telemetry middleware bodies (see its + README). + +Rule for contributors and agents: webview or browser code must never import from +this folder. diff --git a/packages/vscode-ext-webview/src/host/WebviewController.ts b/packages/vscode-ext-webview/src/host/WebviewController.ts new file mode 100644 index 000000000..55e4491fc --- /dev/null +++ b/packages/vscode-ext-webview/src/host/WebviewController.ts @@ -0,0 +1,447 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AnyRouter } from '@trpc/server'; +import { randomBytes } from 'crypto'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { type BaseRouterContext } from '../shared/BaseRouterContext'; +import { type WebviewTrpc } from '../shared/initWebviewTrpc'; +import { attachTrpc, type WebviewCallerFactory } from './attachTrpc'; +import { consoleProcedureLogger, type ProcedureLogger } from './middleware/logging'; + +/** + * Describes where the bundled webview JavaScript lives on disk relative to the + * extension root, both when the extension is shipped as a webpack bundle + * (production) and when it is loaded from `tsc` output (development). + * + * The framework picks the appropriate entry based on the extension's mode + * (`vscode.ExtensionMode.Production` selects `bundled`, otherwise `dev`). In + * development, if the `DEVSERVER` environment variable is set, the controller + * serves the script from {@link WebviewControllerOptions.devServerHost} instead + * of the on-disk location. + */ +export interface WebviewSourceLayout { + /** Layout used in production (bundle on disk). */ + bundled: { dir: string; file: string }; + + /** Layout used in development (loaded from `tsc` output). */ + dev: { dir: string; file: string }; +} + +/** + * Options bag passed to the {@link WebviewController} constructor and (minus + * `extensionContext`) to the {@link openWebview} factory. + * + * @template TRouter - The application's root tRPC router type. + * @template TConfiguration - The configuration object delivered to the webview + * at creation time (received via `useConfiguration`). + * @template TContext - The router context shape (must extend + * {@link BaseRouterContext}). + */ +export interface WebviewControllerOptions< + TRouter extends AnyRouter, + TConfiguration = unknown, + TContext extends BaseRouterContext = BaseRouterContext, +> { + /** The extension context (resource roots, extension mode, on-disk paths). */ + extensionContext: vscode.ExtensionContext; + + /** The title shown in the webview panel tab. */ + title: string; + + /** + * Identifier for this webview. Used both as the panel `viewType` (prefixed + * with `react-webview-`) and as the key passed to the webview's `render()` + * entry so it can look up the matching component from its registry. + */ + viewType: string; + + /** + * The root tRPC router for this application. The controller dispatches + * incoming webview messages against it. + */ + router: TRouter; + + /** + * Your tRPC instance from `initWebviewTrpc<TContext>()`. + * + * When provided, the dispatcher reads `trpc.createCallerFactory` off it, so + * the caller factory always belongs to the same instance that built `router` + * and can never be mismatched — and there is no separate factory to export or + * pass. This is the recommended way to wire a typed-context router. + * + * Only `createCallerFactory` is read, so any `initWebviewTrpc(...)` result is + * accepted even when its instance context is a base of `TContext` (e.g. when + * procedures build on a base-context instance and narrow `ctx` per call). If + * both `trpc` and {@link WebviewControllerOptions.createCallerFactory} are + * given, `trpc` wins. When omitted, the package's shared default instance is + * used — correct only for routers built from the package's default + * `router` / `publicProcedure`. + */ + trpc?: Pick<WebviewTrpc<TContext>, 'createCallerFactory'>; + + /** + * tRPC `createCallerFactory` from your own `initWebviewTrpc(...)` result. + * + * @deprecated Prefer {@link WebviewControllerOptions.trpc}: pass the whole + * `initWebviewTrpc<TContext>()` result and the caller factory is read from it, + * which cannot be mismatched with `router`. This standalone option is still + * honored (and the low-level {@link attachTrpc} primitive still takes an + * explicit `callerFactory`) for embedders who bring their own tRPC instance. + */ + createCallerFactory?: WebviewCallerFactory; + + /** The router context handed to every procedure call. */ + context: TContext; + + /** The initial configuration object the webview reads on startup. */ + config: TConfiguration; + + /** + * Where the webview JavaScript bundle is located. See {@link WebviewSourceLayout}. + */ + sourceLayout: WebviewSourceLayout; + + /** + * Dev-server URL used in development when `process.env.DEVSERVER` is truthy. + * Typically `'http://localhost:18080'`. + * + * Defaults to `'http://localhost:18080'` when omitted. + */ + devServerHost?: string; + + /** + * Whether the running extension is a **webpack bundle** rather than raw + * `tsc` output. This selects which {@link WebviewSourceLayout} entry is used + * (`bundled` when `true`, `dev` when `false`) and therefore which script the + * webview loads. + * + * This is deliberately separate from the extension **mode** (the package can + * derive the mode from `extensionContext`, but it cannot know how the + * consumer built its code). The dev server (`webpack serve`) emits the + * *bundled* asset name (e.g. `views.js`), so a bundled extension running in + * development (mode `Development`, dev server on) must still pick the + * `bundled` layout; keying the layout off the mode alone would ask the dev + * server for the `dev` (tsc) file name and 404. + * + * Consumers pass a flag baked in by their bundler, e.g. an `IS_BUNDLE` + * define (`isBundled: !!process.env.IS_BUNDLE`). + */ + isBundled: boolean; + + /** + * Sink for the zero-config **dispatch logger**: one structured entry per + * completed query, mutation, and subscription, logged at the transport + * boundary. Defaults to {@link consoleProcedureLogger} so the panel logs to + * the extension-host console out of the box; pass your own + * {@link ProcedureLogger} to route the entries elsewhere (an output channel, a + * file), or an inert logger to silence it. + * + * This is deliberately named `logger`, not `telemetry`: it is console/log + * plumbing, not analytics. For Application Insights-style reporting, wire + * {@link telemetryMiddlewareBody} + a `TelemetryRunner` onto your procedures + * (see the package's Observability docs). + */ + logger?: ProcedureLogger; + + /** Optional icon shown in the webview tab. */ + icon?: + | vscode.Uri + | { + readonly light: vscode.Uri; + readonly dark: vscode.Uri; + }; + + /** The view column to open the panel in. Defaults to {@link vscode.ViewColumn.One}. */ + viewColumn?: vscode.ViewColumn; +} + +const DEFAULT_DEV_SERVER_HOST = 'http://localhost:18080'; + +/** + * Serializes a value as JSON for embedding in an inert + * `<script type="application/json">` data block. Escapes `<` (and the JS line + * separators U+2028 / U+2029) so the text can never close the surrounding + * `</script>` element or open an HTML comment, which removes the + * script-context break-out class entirely (R766-N03). Every escape it emits is + * valid JSON, so `JSON.parse(element.textContent)` on the webview side restores + * the original value exactly. + */ +function serializeInertJson(value: unknown): string { + return JSON.stringify(value) + .replace(/</g, '\\u003c') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +/** + * WebviewController manages a `vscode.WebviewPanel` and provides tRPC-based + * communication with the React webview. It handles incoming requests (queries, + * mutations, and subscriptions) from the webview, routing them to server-side + * procedures defined in the injected `appRouter`. + * + * @template TRouter - The application's root tRPC router type. + * @template TConfiguration - The configuration object passed to the webview at + * creation time (received in the webview via + * {@link useConfiguration}). + * @template TContext - The router context shape (must extend + * {@link BaseRouterContext}). + */ +export class WebviewController< + TRouter extends AnyRouter, + TConfiguration = unknown, + TContext extends BaseRouterContext = BaseRouterContext, +> + implements vscode.Disposable +{ + private _panel: vscode.WebviewPanel; + private _disposables: vscode.Disposable[] = []; + private _isDisposed: boolean = false; + /** + * `true` once VS Code is tearing the panel down on its own (the user closed + * the tab). Set by the `onDidDispose` handler before it calls `dispose()`, so + * `dispose()` knows not to close an already-closing panel a second time. + */ + private _panelDisposed: boolean = false; + private _onDisposed: vscode.EventEmitter<void> = new vscode.EventEmitter<void>(); + public readonly onDisposed: vscode.Event<void> = this._onDisposed.event; + + private readonly _options: WebviewControllerOptions<TRouter, TConfiguration, TContext>; + + /** + * Creates a new WebviewController instance. + * + * Opens the panel, renders its HTML, and wires the tRPC dispatch pump (via + * {@link attachTrpc}) against `options.router` using `options.context`. The + * dispatch logger defaults to {@link consoleProcedureLogger}. + * + * @param options - The controller options. See {@link WebviewControllerOptions}. + */ + constructor(options: WebviewControllerOptions<TRouter, TConfiguration, TContext>) { + this._options = options; + + const viewColumn = options.viewColumn ?? vscode.ViewColumn.One; + + this._panel = vscode.window.createWebviewPanel('react-webview-' + options.viewType, options.title, viewColumn, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(options.extensionContext.extensionPath)], + }); + + this._panel.webview.html = this.getDocumentTemplate(this._panel.webview); + this._panel.iconPath = options.icon; + + // Register the onDisposed emitter so dispose() releases its subscriber list. + // It is intentionally not part of the disposables chain that fires _onDisposed + // itself — dispose() fires the event first, then disposes the rest (this entry + // included), so subscribers see the notification before the emitter is torn down. + this.registerDisposable(this._onDisposed); + + this.registerDisposable( + this._panel.onDidDispose(() => { + // VS Code is disposing the panel (the user closed the tab). Record that + // so dispose() does not try to close an already-closing panel, then run + // the normal teardown. + this._panelDisposed = true; + this.dispose(); + }), + ); + + this.setupTrpc(options.context); + } + + /** + * Sets up tRPC integration for the webview. This includes listening for + * messages from the webview, parsing them as tRPC operations (queries, + * mutations, subscriptions, or subscription stops), invoking the + * appropriate server-side procedures, and returning results or errors. + * + * @param context - The router context for procedure calls. + */ + protected setupTrpc(context: TContext): void { + // The dispatch pump (message handling, abort / subscription lifecycle) + // lives in the free `attachTrpc` primitive. The controller simply wires + // it to its panel and registers the returned disposable so the listener + // and all in-flight operations are torn down on dispose. The dispatch + // logger defaults to the zero-config console sink. + // + // R766-N02: prefer the caller factory carried by the `trpc` instance (it + // cannot be mismatched with `router`); fall back to the deprecated + // standalone `createCallerFactory`, then to `attachTrpc`'s own default. + const callerFactory: WebviewCallerFactory | undefined = + this._options.trpc?.createCallerFactory ?? this._options.createCallerFactory; + const { disposable } = attachTrpc( + this._panel, + context, + this._options.router, + callerFactory, + this._options.logger ?? consoleProcedureLogger, + ); + this.registerDisposable(disposable); + } + + /** + * Generates the full HTML document for the webview, including CSP headers, + * serialized initial configuration, and the script that boots the React app. + */ + private getDocumentTemplate(webview?: vscode.Webview): string { + const devServer = !!process.env.DEVSERVER; + const isProduction = this._options.extensionContext.extensionMode === vscode.ExtensionMode.Production; + const nonce = randomBytes(16).toString('base64'); + + // The layout (which script the webview loads) is chosen by whether the + // running extension is a webpack bundle, not by the extension mode: the + // dev server serves the bundled asset name, so a bundled extension in + // development must still resolve the `bundled` layout. `isProduction` is + // used only for CSP hardening and the disk-vs-dev-server choice below. + const layout = this._options.isBundled ? this._options.sourceLayout.bundled : this._options.sourceLayout.dev; + const devServerHost = this._options.devServerHost ?? DEFAULT_DEV_SERVER_HOST; + + const uri = (...parts: string[]) => + webview + ?.asWebviewUri( + vscode.Uri.file(path.join(this._options.extensionContext.extensionPath, layout.dir, ...parts)), + ) + .toString(true); + + const srcUri = isProduction || !devServer ? uri(layout.file) : `${devServerHost}/${layout.file}`; + + const csp = ( + isProduction + ? [ + `form-action 'none';`, + `default-src ${webview?.cspSource};`, + `script-src ${webview?.cspSource} 'nonce-${nonce}';`, + `style-src ${webview?.cspSource} vscode-resource: 'unsafe-inline';`, + `img-src ${webview?.cspSource} data: vscode-resource:;`, + `connect-src ${webview?.cspSource} ws:;`, + `font-src ${webview?.cspSource};`, + `worker-src ${webview?.cspSource} blob:;`, + ] + : [ + `form-action 'none';`, + `default-src ${webview?.cspSource} ${devServerHost};`, + `script-src ${webview?.cspSource} ${devServerHost} 'nonce-${nonce}';`, + `style-src ${webview?.cspSource} ${devServerHost} vscode-resource: 'unsafe-inline';`, + `img-src ${webview?.cspSource} ${devServerHost} data: vscode-resource:;`, + `connect-src ${webview?.cspSource} ${devServerHost} ws:;`, + `font-src ${webview?.cspSource} ${devServerHost};`, + `worker-src ${webview?.cspSource} ${devServerHost} blob:;`, + ] + ).join(' '); + + /** + * R766-N03: the initial data (encoded config, l10n bundle, viewType) is + * delivered in an **inert** `application/json` data block and read by a + * nonce'd boot script, instead of being interpolated into executable + * inline scripts. `serializeInertJson` escapes `<`, so the block can never + * break out of its `</script>`. `initialData` stays + * `encodeURIComponent(JSON.stringify(config))` — this both avoids crashing + * the webview on "unsupported" bytes and keeps `useConfiguration` + * (which does `decodeURIComponent` + `JSON.parse`) unchanged. + */ + const initialDataJson = serializeInertJson({ + initialData: encodeURIComponent(JSON.stringify(this._options.config)), + l10n: vscode.l10n.bundle ?? {}, + viewType: this._options.viewType, + }); + + return `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta // noinspection JSAnnotator + http-equiv="Content-Security-Policy" content="${csp}" /> + </head> + <body> + <div id="root"></div> + <script type="application/json" id="vscode-ext-webview-initial-data" nonce="${nonce}">${initialDataJson}</script> + <script type="module" nonce="${nonce}"> + const __el = document.getElementById('vscode-ext-webview-initial-data'); + const __data = JSON.parse(__el.textContent); + globalThis.l10n_bundle = __data.l10n; + window.config = { ...window.config, __initialData: __data.initialData }; + + import { render } from "${srcUri}"; + render(__data.viewType, acquireVsCodeApi()); + </script> + + </body> + </html>`; + } + + protected registerDisposable(disposable: vscode.Disposable): void { + this._disposables.push(disposable); + } + + /** + * Gets whether the controller has been disposed. + */ + public get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Gets the vscode.WebviewPanel that the controller is managing. + */ + public get panel(): vscode.WebviewPanel { + return this._panel; + } + + /** + * Reveals the webview in the given column, bringing it to the foreground. + * Useful if the webview is already open but hidden. + * + * @param viewColumn The column to reveal in. Defaults to ViewColumn.One. + */ + public revealToForeground(viewColumn: vscode.ViewColumn = vscode.ViewColumn.One): void { + this._panel.reveal(viewColumn, true); + } + + /** + * Disposes the controller: notifies `onDisposed` subscribers, tears down the + * tRPC dispatch pump (aborting in-flight operations and subscriptions), and + * closes the webview panel/tab. + * + * **Two entry points, one outcome.** Either the consumer calls `dispose()` + * directly (to close the view), or the user closes the tab and VS Code fires + * `onDidDispose`, whose handler calls `dispose()`. Both paths must end with the + * panel closed and every resource released, so this method does both. A public + * handle whose `dispose()` left the tab open would be a surprising API. + * + * **No recursion.** `_isDisposed` is set before anything else, so the + * re-entrant `dispose → panel.dispose → onDidDispose → dispose` call returns at + * the guard. On the `onDidDispose` path `_panelDisposed` is already `true`, so + * the redundant `this._panel.dispose()` is skipped. + */ + public dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + + // Notify subscribers before tearing anything down, so they observe the + // disposal while the object graph is still intact. + this._onDisposed.fire(); + + // Dispose registered resources: the tRPC dispatch disposable returned by + // `attachTrpc` (removes the message listener and aborts every in-flight + // operation and subscription, calling `return()` on each iterator so async + // generators parked on `next()` terminate cleanly), the `onDidDispose` + // listener, and the `onDisposed` emitter. + this._disposables.forEach((d) => { + d.dispose(); + }); + + // Close the panel/tab unless VS Code is already doing so (the `onDidDispose` + // path set `_panelDisposed`). Disposing the listeners above already removed + // our `onDidDispose` subscription, so this call cannot re-enter `dispose()`. + if (!this._panelDisposed) { + this._panel.dispose(); + } + } +} diff --git a/packages/vscode-ext-webview/src/host/attachTrpc.test.ts b/packages/vscode-ext-webview/src/host/attachTrpc.test.ts new file mode 100644 index 000000000..a2984c02a --- /dev/null +++ b/packages/vscode-ext-webview/src/host/attachTrpc.test.ts @@ -0,0 +1,405 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type WebviewPanel } from 'vscode'; +import { type BaseRouterContext } from '../shared/BaseRouterContext'; +import { initWebviewTrpc } from '../shared/initWebviewTrpc'; +import { TypedEventSink } from '../shared/TypedEventSink'; +import { type VsCodeLinkRequestMessage } from '../shared/wireProtocol'; +import { attachTrpc } from './attachTrpc'; +import { type ProcedureLogEntry, type ProcedureLogger } from './middleware/logging'; + +type PostedMessage = { id: string; result?: unknown; error?: { message: string }; complete?: boolean }; + +/** + * Minimal stub of the parts of `vscode.WebviewPanel` that `attachTrpc` touches: + * `webview.onDidReceiveMessage` (to capture the dispatcher's handler) and + * `webview.postMessage` (to capture what the dispatcher sends back). + */ +function createStubPanel() { + let handler: ((m: VsCodeLinkRequestMessage) => unknown) | undefined; + let listenerDisposed = false; + const posted: PostedMessage[] = []; + + const panel = { + webview: { + onDidReceiveMessage(cb: (m: VsCodeLinkRequestMessage) => unknown) { + handler = cb; + return { + dispose() { + listenerDisposed = true; + }, + }; + }, + postMessage(message: PostedMessage) { + posted.push(message); + return Promise.resolve(true); + }, + }, + } as unknown as WebviewPanel; + + return { + panel, + posted, + isListenerDisposed: () => listenerDisposed, + async send(message: VsCodeLinkRequestMessage): Promise<void> { + await handler?.(message); + }, + }; +} + +/** Lets queued microtasks / awaited generator steps settle. */ +const flush = (): Promise<void> => new Promise((resolve) => setImmediate(resolve)); + +function makeMessage( + id: string, + type: 'query' | 'mutation' | 'subscription' | 'abort' | 'subscription.stop', + path: string, + input?: unknown, +): VsCodeLinkRequestMessage { + return { + id, + op: { id: 0, type, path, input, context: {} } as VsCodeLinkRequestMessage['op'], + }; +} + +describe('attachTrpc', () => { + it('dispatches a query and posts the result', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hello'), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('q1', 'query', 'greet')); + + expect(stub.posted).toEqual([{ id: 'q1', result: 'hello' }]); + }); + + it('coalesces an undefined mutation result to null so it survives postMessage', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + doIt: publicProcedure.mutation(() => undefined), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('m1', 'mutation', 'doIt')); + + expect(stub.posted).toEqual([{ id: 'm1', result: null }]); + }); + + it('posts an error message when a procedure throws', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + boom: publicProcedure.query(() => { + throw new Error('kaboom'); + }), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('e1', 'query', 'boom')); + + expect(stub.posted).toHaveLength(1); + expect(stub.posted[0].id).toBe('e1'); + expect(stub.posted[0].error?.message).toBe('kaboom'); + }); + + it('dispatches against a nested router by dotted path', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + outer: router({ + inner: publicProcedure.query(() => 'deep'), + }), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('n1', 'query', 'outer.inner')); + + expect(stub.posted).toEqual([{ id: 'n1', result: 'deep' }]); + }); + + it('streams subscription yields and a final complete', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + counter: publicProcedure.subscription(async function* () { + await Promise.resolve(); + yield 1; + yield 2; + }), + }); + + const stub = createStubPanel(); + const { activeSubscriptions } = attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('s1', 'subscription', 'counter')); + await flush(); + await flush(); + + expect(stub.posted).toEqual([ + { id: 's1', result: 1 }, + { id: 's1', result: 2 }, + { id: 's1', complete: true }, + ]); + expect(activeSubscriptions.size).toBe(0); + }); + + it('aborts an in-flight operation on an abort message', async () => { + let observedAborted: boolean | undefined; + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + slow: publicProcedure.query(async ({ ctx }: { ctx: BaseRouterContext }) => { + await gate; + observedAborted = ctx.signal?.aborted; + return 'done'; + }), + }); + + const stub = createStubPanel(); + const { activeOperations } = attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + const inflight = stub.send(makeMessage('a1', 'query', 'slow')); + await flush(); + expect(activeOperations.has('a1')).toBe(true); + + void stub.send(makeMessage('a1', 'abort', 'slow')); + expect(activeOperations.has('a1')).toBe(false); + + release(); + await inflight; + await flush(); + + // The per-operation signal fired, and no result was posted for the aborted op. + expect(observedAborted).toBe(true); + expect(stub.posted).toHaveLength(0); + }); + + it('stops a subscription: aborts its controller and clears tracking', async () => { + const sink = new TypedEventSink<{ type: 'tick' }>(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + forever: publicProcedure.subscription(async function* () { + for await (const event of sink) { + yield event; + } + }), + }); + + const stub = createStubPanel(); + const { activeSubscriptions } = attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('sub', 'subscription', 'forever')); + await flush(); + + const entry = activeSubscriptions.get('sub'); + expect(entry).toBeDefined(); + + void stub.send(makeMessage('sub', 'subscription.stop', 'forever')); + + // The stop handler synchronously aborts the per-operation controller, + // calls `iterator.return()`, and drops the tracking entry. + expect(activeSubscriptions.has('sub')).toBe(false); + expect(entry?.abortController.signal.aborted).toBe(true); + }); + + it('completes a subscription when its event sink closes', async () => { + const sink = new TypedEventSink<{ type: 'tick' }>(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + ticks: publicProcedure.subscription(async function* () { + for await (const event of sink) { + yield event; + } + }), + }); + + const stub = createStubPanel(); + const { activeSubscriptions } = attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('t1', 'subscription', 'ticks')); + await flush(); + + sink.emit({ type: 'tick' }); + await flush(); + sink.close(); + await flush(); + + expect(stub.posted).toEqual([ + { id: 't1', result: { type: 'tick' } }, + { id: 't1', complete: true }, + ]); + expect(activeSubscriptions.size).toBe(0); + }); + + it('disposes the listener and aborts in-flight work on dispose', async () => { + let observedAborted: boolean | undefined; + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + slow: publicProcedure.query(async ({ ctx }: { ctx: BaseRouterContext }) => { + await gate; + observedAborted = ctx.signal?.aborted; + return 'done'; + }), + }); + + const stub = createStubPanel(); + const { disposable, activeOperations } = attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + const inflight = stub.send(makeMessage('d1', 'query', 'slow')); + await flush(); + expect(activeOperations.has('d1')).toBe(true); + + disposable.dispose(); + + expect(stub.isListenerDisposed()).toBe(true); + expect(activeOperations.size).toBe(0); + + release(); + await inflight; + await flush(); + expect(observedAborted).toBe(true); + }); + + it('logs one entry per completed query when a logger is supplied', async () => { + const entries: ProcedureLogEntry[] = []; + const logger: ProcedureLogger = { log: (entry) => entries.push(entry) }; + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hello'), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory, logger); + + await stub.send(makeMessage('q1', 'query', 'greet')); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ type: 'query', path: 'greet', ok: true, aborted: false }); + expect(typeof entries[0].durationMs).toBe('number'); + }); + + it('stamps each log entry with the concurrent in-flight count (R766-S04)', async () => { + const entries: ProcedureLogEntry[] = []; + const logger: ProcedureLogger = { log: (entry) => entries.push(entry) }; + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hello'), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory, logger); + + await stub.send(makeMessage('q1', 'query', 'greet')); + + // The completing operation is still tracked when it is logged, so the + // concurrency gauge includes it (exactly one op in flight here). + expect(entries).toHaveLength(1); + expect(entries[0].concurrent).toBe(1); + }); + + it('logs a failed entry (ok: false) when a procedure throws', async () => { + const entries: ProcedureLogEntry[] = []; + const logger: ProcedureLogger = { log: (entry) => entries.push(entry) }; + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + boom: publicProcedure.query(() => { + throw new Error('kaboom'); + }), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory, logger); + + await stub.send(makeMessage('e1', 'query', 'boom')); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ type: 'query', path: 'boom', ok: false }); + expect(entries[0].error?.message).toBe('kaboom'); + }); + + it('logs one subscription entry on natural completion', async () => { + const entries: ProcedureLogEntry[] = []; + const logger: ProcedureLogger = { log: (entry) => entries.push(entry) }; + + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + counter: publicProcedure.subscription(async function* () { + await Promise.resolve(); + yield 1; + }), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory, logger); + + await stub.send(makeMessage('s1', 'subscription', 'counter')); + await flush(); + await flush(); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ type: 'subscription', path: 'counter', ok: true }); + }); + + it('does not log anything when no logger is supplied', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hello'), + }); + + const stub = createStubPanel(); + // No logger argument: dispatch-level logging is disabled. The assertion is + // simply that dispatch still works (no throw) and the result is posted. + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + await stub.send(makeMessage('q1', 'query', 'greet')); + + expect(stub.posted).toEqual([{ id: 'q1', result: 'hello' }]); + }); + + it('ignores foreign (non-tRPC) messages and still serves later queries [R766-01]', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hello'), + }); + + const stub = createStubPanel(); + attachTrpc(stub.panel, {}, appRouter, createCallerFactory); + + // Traffic a bring-your-own-panel embedder might route over the same bus. + // None is a well-formed transport request, so each must be ignored without + // throwing (the listener is async, so a throw would become an unhandled + // rejection) and none may produce a response. + await expect( + stub.send({ command: 'somethingElse', value: 42 } as unknown as VsCodeLinkRequestMessage), + ).resolves.toBeUndefined(); + await expect(stub.send(null as unknown as VsCodeLinkRequestMessage)).resolves.toBeUndefined(); + await expect(stub.send({ id: 'x' } as unknown as VsCodeLinkRequestMessage)).resolves.toBeUndefined(); + expect(stub.posted).toHaveLength(0); + + // A real tRPC query still dispatches normally afterwards. + await stub.send(makeMessage('q1', 'query', 'greet')); + expect(stub.posted).toEqual([{ id: 'q1', result: 'hello' }]); + }); +}); diff --git a/packages/vscode-ext-webview/src/host/attachTrpc.ts b/packages/vscode-ext-webview/src/host/attachTrpc.ts new file mode 100644 index 000000000..839bc4947 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/attachTrpc.ts @@ -0,0 +1,451 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * `attachTrpc` is the host-side transport primitive: it wires a tRPC router to a + * `vscode.WebviewPanel`, owning the message pump that dispatches incoming + * queries, mutations, and subscriptions and streams results back over + * `postMessage`. + * + * It is the "bring your own panel" entry point. Consumers that already own a + * panel (a custom tab/base class) call `attachTrpc(panel, ctx, router)` directly; + * {@link WebviewController} and the {@link openWebview} factory are conveniences + * layered on top of it. + * + * This module imports `vscode` only as types, so it carries no runtime + * dependency on the VS Code API and is unit-testable with a stub panel. + */ + +import { getTRPCErrorFromUnknown, type AnyRouter } from '@trpc/server'; +import { type Disposable, type WebviewPanel } from 'vscode'; +import { type BaseRouterContext } from '../shared/BaseRouterContext'; +import { createCallerFactory as defaultCreateCallerFactory } from '../shared/initWebviewTrpc'; +import { type VsCodeLinkRequestMessage } from '../shared/wireProtocol'; +import { type ProcedureLogEntry, type ProcedureLogger } from './middleware/logging'; +import { type ProcedureType } from './middleware/types'; + +/** + * A tracked subscription: its per-operation `AbortController` plus the live + * `AsyncIterator` driving the stream, so the pump can call `iterator.return()` + * on `subscription.stop` and on teardown. + */ +export interface ActiveSubscription { + abortController: AbortController; + iterator: AsyncIterator<unknown>; +} + +/** + * Structural shape of a tRPC `createCallerFactory` as the dispatcher consumes + * it: given a router it returns a function that, given a context, returns a + * caller. + * + * The context parameter is intentionally `any` so a `createCallerFactory` bound + * to any consumer `TContext` (from their own `initWebviewTrpc(...)` result) is + * assignable without a cast. The dispatcher passes the per-operation context it + * was handed. + */ +export type WebviewCallerFactory = ( + router: AnyRouter, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tRPC caller factories are bound to a specific context type; `any` lets any consumer's factory be passed without a cast. +) => (ctx: any) => unknown; + +/** + * The handle returned by {@link attachTrpc}. + */ +export interface AttachTrpcResult { + /** + * Disposes the message listener and aborts every in-flight operation and + * subscription. Register it with your panel's lifecycle. + */ + disposable: Disposable; + + /** + * Read-only view of the in-flight queries / mutations by operation id. + * + * Exposed for observation (counts, ids, live inspection); it is the same map + * the dispatcher mutates, so it updates as operations start and finish. It is + * typed `ReadonlyMap` on purpose: mutating it would corrupt the dispatcher's + * cancellation bookkeeping. Cancel work through the client's `AbortSignal` or + * dispose the whole transport via {@link AttachTrpcResult.disposable}. + */ + activeOperations: ReadonlyMap<string, AbortController>; + + /** + * Read-only view of the open subscriptions by operation id. See + * {@link AttachTrpcResult.activeOperations} for why it is `ReadonlyMap`. + */ + activeSubscriptions: ReadonlyMap<string, ActiveSubscription>; +} + +/** + * Normalizes a procedure's subscription return value (which may be an + * `AsyncIterable`, an `AsyncIterator`, or both - async generators are both) to a + * single live `AsyncIterator`. + * + * Calling `[Symbol.asyncIterator]()` once is required for iterables like + * `TypedEventSink` (which enforce single-consumer semantics); direct iterators + * are returned as-is. + */ +function toAsyncIterator(value: unknown): AsyncIterator<unknown> { + if ( + value !== null && + typeof value === 'object' && + typeof (value as AsyncIterable<unknown>)[Symbol.asyncIterator] === 'function' + ) { + return (value as AsyncIterable<unknown>)[Symbol.asyncIterator](); + } + return value as AsyncIterator<unknown>; +} + +/** + * Converts an unknown error into a tRPC-compatible error response. Building a + * plain object with enumerable properties ensures the client receives a properly + * serialized error over `postMessage`. + */ +function wrapInTrpcErrorMessage(error: unknown, operationId: string) { + const errorEntry = getTRPCErrorFromUnknown(error); + + return { + id: operationId, + error: { + code: errorEntry.code, + name: errorEntry.name, + message: errorEntry.message, + stack: errorEntry.stack, + cause: errorEntry.cause, + }, + }; +} + +/** + * Runtime guard: is this inbound `postMessage` payload a well-formed tRPC + * transport request, as opposed to unrelated traffic on a shared panel? + * + * `attachTrpc` behaves as a guest on `panel.webview`: a bring-your-own-panel + * embedder may route its own `postMessage` protocol over the same bus. Foreign + * messages have no `op`, so touching `message.op.type` would throw. This guard + * lets the dispatcher ignore anything that is not shaped like a + * {@link VsCodeLinkRequestMessage}. It intentionally checks structure only (an + * `id` string plus an `op` object with a string `type`) rather than a fixed set + * of op types, so forward-compatible tRPC operations still dispatch. + */ +function isTransportRequestMessage(message: unknown): message is VsCodeLinkRequestMessage { + if (message === null || typeof message !== 'object') { + return false; + } + const op = (message as { op?: unknown }).op; + return ( + typeof (message as { id?: unknown }).id === 'string' && + op !== null && + typeof op === 'object' && + typeof (op as { type?: unknown }).type === 'string' + ); +} + +/** + * Attaches a tRPC dispatch pump to a webview panel. + * + * Listens for {@link VsCodeLinkRequestMessage}s on `panel.webview`, routes each + * to the matching procedure on `router` using `context` (cloned per operation + * with a fresh `AbortSignal`), and posts results / errors / completion back. + * + * @param panel - the panel whose `webview` carries the transport. + * @param context - the base router context for procedure calls; each + * operation receives a shallow clone with its own + * `signal`. + * @param router - the application's root tRPC router. + * @param callerFactory - tRPC `createCallerFactory`, defaulting to the package's + * shared default instance. Pass the one from your own + * `initWebviewTrpc(...)` result when your router is built + * with a typed context. + * @param logger - optional {@link ProcedureLogger}. When provided, one + * structured entry is logged per completed query, + * mutation, and subscription (the zero-config console + * telemetry path; {@link WebviewController} defaults it to + * {@link consoleProcedureLogger}). Omit it to disable + * dispatch-level logging entirely. + */ +export function attachTrpc<TRouter extends AnyRouter, TContext extends BaseRouterContext>( + panel: WebviewPanel, + context: TContext, + router: TRouter, + callerFactory: WebviewCallerFactory = defaultCreateCallerFactory, + logger?: ProcedureLogger, +): AttachTrpcResult { + const activeOperations = new Map<string, AbortController>(); + const activeSubscriptions = new Map<string, ActiveSubscription>(); + let disposed = false; + + /** + * Safely posts a message to the webview. Calling `postMessage` after the + * panel has been disposed can throw synchronously or reject depending on the + * VS Code version, so this guards both shapes. Returns `false` if delivery + * could not be attempted; the boolean is informational. + */ + const safePostMessage = (message: unknown): boolean => { + if (disposed) { + return false; + } + try { + void Promise.resolve(panel.webview.postMessage(message)).catch(() => void 0); + return true; + } catch { + return false; + } + }; + + /** + * Logs a completed procedure through the optional {@link ProcedureLogger}, + * stamping it with the number of concurrent in-flight operations (R766-S04). + * Every log site fires while the operation is still tracked, so the count + * includes the operation being logged. + */ + const logProcedure = (entry: ProcedureLogEntry): void => { + logger?.log({ ...entry, concurrent: activeOperations.size + activeSubscriptions.size }); + }; + + const handleSubscriptionMessage = async (message: VsCodeLinkRequestMessage): Promise<void> => { + // In v12, tRPC will have better cancellation support. For now, we use AbortController. + const abortController = new AbortController(); + const start = Date.now(); + + try { + // Clone context so the signal is per-operation and does not mutate the shared context object + const opContext: TContext = { ...context, signal: abortController.signal }; + + const caller = callerFactory(router)(opContext); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + const procedure = (caller as any)[message.op.path]; + + if (typeof procedure !== 'function') { + // Framework-internal protocol error; not localized - consumers cannot translate it + // and this code path indicates a programming error in the caller (wrong path). + throw new Error(`Procedure not found: ${message.op.path}`); + } + + // Await the procedure call to get the async iterable (the procedure's `async function*` + // result, which is an AsyncGenerator and therefore both iterable and an iterator). + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + const asyncIterable = await procedure(message.op.input); + + // Normalize to a live AsyncIterator and store it. We deliberately do *not* use + // `for await (const value of asyncIterable)` because that would obtain the iterator + // internally and give us no handle to call `iterator.return()` on `subscription.stop` + // or panel dispose. Driving `next()`/`return()` ourselves is what lets us release + // consumers parked on a pending next (e.g. an event sink with no recent emit). + const iterator: AsyncIterator<unknown> = toAsyncIterator(asyncIterable); + + // Only track the subscription once we actually have an iterator. If procedure + // lookup or the initial `await procedure(...)` throws, we fall through to the + // outer catch without ever inserting an entry - so an early failure cannot + // leave a stale (id, AbortController) pair behind for the lifetime of the panel. + activeSubscriptions.set(message.id, { abortController, iterator }); + + void (async () => { + try { + while (true) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { value, done } = await iterator.next(); + if (done) { + break; + } + // Each yielded value is sent to the webview + safePostMessage({ id: message.id, result: value }); + } + + // On natural completion (procedure returned, or our `return()` propagated + // through the generator), inform the client. + safePostMessage({ id: message.id, complete: true }); + logProcedure({ + type: 'subscription', + path: message.op.path, + durationMs: Date.now() - start, + ok: true, + aborted: abortController.signal.aborted, + }); + } catch (error) { + safePostMessage(wrapInTrpcErrorMessage(error, message.id)); + logProcedure({ + type: 'subscription', + path: message.op.path, + durationMs: Date.now() - start, + ok: false, + aborted: abortController.signal.aborted, + error: getTRPCErrorFromUnknown(error), + }); + } finally { + activeSubscriptions.delete(message.id); + } + })(); + } catch (error) { + safePostMessage(wrapInTrpcErrorMessage(error, message.id)); + logProcedure({ + type: 'subscription', + path: message.op.path, + durationMs: Date.now() - start, + ok: false, + aborted: abortController.signal.aborted, + error: getTRPCErrorFromUnknown(error), + }); + } + }; + + const handleSubscriptionStopMessage = (message: VsCodeLinkRequestMessage): void => { + const record = activeSubscriptions.get(message.id); + if (record) { + record.abortController.abort(); + // Cooperative abort cannot unblock a parked `iterator.next()`. Calling `return()` + // here propagates through the procedure's async generator into any inner + // `for await` (including `TypedEventSink` consumers), which lets parked + // promises settle with `{ done: true }` and the streaming task exit cleanly. + // `AsyncIterator.return(value?)` takes an optional *return value*, not an + // `IteratorResult`; we have no domain value to return, so we call it with no + // argument. We swallow rejection from `return()` because we have no useful reaction. + void Promise.resolve(record.iterator.return?.()).catch(() => void 0); + activeSubscriptions.delete(message.id); + } + }; + + const handleAbortMessage = (message: VsCodeLinkRequestMessage): void => { + const abortController = activeOperations.get(message.id); + if (abortController) { + abortController.abort(); + activeOperations.delete(message.id); + } + }; + + const handleDefaultMessage = async (message: VsCodeLinkRequestMessage): Promise<void> => { + // In v12, tRPC will have better cancellation support. For now, we use AbortController. + const abortController = new AbortController(); + activeOperations.set(message.id, abortController); + const start = Date.now(); + + try { + // Clone context so the signal is per-operation and does not mutate the shared context object + const opContext: TContext = { ...context, signal: abortController.signal }; + + const caller = callerFactory(router)(opContext); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + const procedure = (caller as any)[message.op.path]; + + if (typeof procedure !== 'function') { + // Framework-internal protocol error; not localized - see handleSubscriptionMessage(). + throw new Error(`Procedure not found: ${message.op.path}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + const result = await procedure(message.op.input); + + // Only send the result if the operation was not aborted + if (!abortController.signal.aborted) { + // Coalesce undefined -> null so the `result` key survives structured-clone + // serialization over postMessage (undefined values are stripped by the + // structured-clone algorithm, which would cause the client-side observable + // to never complete for void mutations). + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const response = { id: message.id, result: result ?? null }; + safePostMessage(response); + } + + logProcedure({ + type: message.op.type as ProcedureType, + path: message.op.path, + durationMs: Date.now() - start, + ok: true, + aborted: abortController.signal.aborted, + }); + } catch (error) { + // Only send error if the operation was not aborted (client already errored locally) + if (!abortController.signal.aborted) { + safePostMessage(wrapInTrpcErrorMessage(error, message.id)); + } + logProcedure({ + type: message.op.type as ProcedureType, + path: message.op.path, + durationMs: Date.now() - start, + ok: false, + aborted: abortController.signal.aborted, + error: getTRPCErrorFromUnknown(error), + }); + } finally { + activeOperations.delete(message.id); + } + }; + + const listener = panel.webview.onDidReceiveMessage(async (message: unknown) => { + // R766-01: `attachTrpc` may be attached to a panel that also carries a + // consumer's own (non-tRPC) `postMessage` traffic. Such a message has no + // `op`, so dispatching it would throw; because this listener is `async` + // that throw would surface as an *unhandled promise rejection*. Ignore + // anything that is not shaped like a transport request, exactly as a guest + // on a shared bus should. + if (!isTransportRequestMessage(message)) { + return; + } + + // R766-01 (defence in depth): keep any handler throw from escaping this + // async listener as an unhandled rejection. The per-operation handlers + // already post tRPC error responses to the client for procedure failures; + // this guards the dispatch/control path itself (and the whole class of + // future bugs), so one malformed operation can never take down the pump. + try { + switch (message.op.type) { + case 'subscription': + await handleSubscriptionMessage(message); + break; + + case 'subscription.stop': + handleSubscriptionStopMessage(message); + break; + + case 'abort': + handleAbortMessage(message); + break; + + default: + await handleDefaultMessage(message); + break; + } + } catch { + // Swallow: a throw here would otherwise become an unhandled rejection. + // Per-operation handlers own client-facing error reporting. + } + }); + + const disposable: Disposable = { + dispose(): void { + if (disposed) { + return; + } + disposed = true; + + listener.dispose(); + + // Abort all active queries/mutations so server-side procedures can stop early. + for (const controller of activeOperations.values()) { + controller.abort(); + } + activeOperations.clear(); + + // Abort all active subscriptions and call `return()` on each iterator so async + // generators terminate even when parked on `next()`. The abort signal alone + // cannot unblock a parked `next()`; `return()` propagates through the procedure's + // `for await` into any inner event sink and settles its pending promise. + // `AsyncIterator.return(value?)` takes an optional *return value*, not an + // `IteratorResult`; there is no domain value to return during shutdown, so it is + // called with no argument. Rejections from `return()` are swallowed because we + // have no useful reaction during shutdown. + for (const { abortController, iterator } of activeSubscriptions.values()) { + abortController.abort(); + void Promise.resolve(iterator.return?.()).catch(() => void 0); + } + activeSubscriptions.clear(); + }, + }; + + return { disposable, activeOperations, activeSubscriptions }; +} diff --git a/packages/vscode-ext-webview/src/host/index.ts b/packages/vscode-ext-webview/src/host/index.ts new file mode 100644 index 000000000..9c1881bd9 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/index.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Host surface of `@microsoft/vscode-ext-webview` (the `./host` subpath). + * + * Extension-host (Node.js) code imports from here. It pulls in `vscode` and + * Node APIs and must not be bundled into the webview. + * + * The tRPC builders (`initWebviewTrpc`, `router`, `publicProcedure`) live in the + * shared `.` entry; this entry owns the panel facade + * ({@link WebviewController}) and the instance-agnostic middleware bodies + + * adapters. + */ + +export { type AnyRouter } from '@trpc/server'; +export { attachTrpc, type ActiveSubscription, type AttachTrpcResult, type WebviewCallerFactory } from './attachTrpc'; +export { + consoleProcedureLogger, + getInvocationSignal, + loggingMiddlewareBody, + telemetryMiddlewareBody, + type MiddlewareResultLike, + type ProcedureErrorLike, + type ProcedureInvocation, + type ProcedureLogEntry, + type ProcedureLogger, + type ProcedureTelemetry, + type ProcedureType, + type TelemetryRunner, + type WithTelemetry, +} from './middleware'; +export { openWebview } from './openWebview'; +export { WebviewController, type WebviewControllerOptions, type WebviewSourceLayout } from './WebviewController'; diff --git a/packages/vscode-ext-webview/src/host/middleware/README.md b/packages/vscode-ext-webview/src/host/middleware/README.md new file mode 100644 index 000000000..b94ca1ff3 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/README.md @@ -0,0 +1,20 @@ +# host/middleware + +Optional, batteries-included tRPC middleware bodies. A middleware wraps every +procedure call and can do anything (logging, telemetry, auth, validation, and so +on); these are the two the package ships. + +- `logging.ts`: `loggingMiddlewareBody` plus the `ProcedureLogger` sink. + `consoleProcedureLogger` is the zero-config default. +- `telemetry.ts`: `telemetryMiddlewareBody` plus the `TelemetryRunner` adapter + (for example, Application Insights via `@microsoft/vscode-azext-utils`). +- `types.ts`: the shared invocation and result types the bodies read. + +A "body" is the unbound inner function, so it is not tied to one tRPC instance. +Wire one onto your own procedure: + +```ts +publicProcedure.use((opts) => loggingMiddlewareBody(opts, myLogger)); +``` + +You are not limited to these two. Write your own middleware the same way. diff --git a/packages/vscode-ext-webview/src/host/middleware/index.ts b/packages/vscode-ext-webview/src/host/middleware/index.ts new file mode 100644 index 000000000..eb6a36074 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/index.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Instance-agnostic middleware bodies and their adapter interfaces. + * + * Wire a body onto your own tRPC instance with + * `publicProcedure.use((opts) => body(opts, adapter))`. + */ + +export { consoleProcedureLogger, loggingMiddlewareBody, type ProcedureLogEntry, type ProcedureLogger } from './logging'; +export { + telemetryMiddlewareBody, + type ProcedureTelemetry, + type TelemetryRunner, + type WithTelemetry, +} from './telemetry'; +export { + getInvocationSignal, + type MiddlewareResultLike, + type ProcedureErrorLike, + type ProcedureInvocation, + type ProcedureType, +} from './types'; diff --git a/packages/vscode-ext-webview/src/host/middleware/logging.test.ts b/packages/vscode-ext-webview/src/host/middleware/logging.test.ts new file mode 100644 index 000000000..3d5dbbc35 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/logging.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type BaseRouterContext } from '../../shared/BaseRouterContext'; +import { initWebviewTrpc } from '../../shared/initWebviewTrpc'; +import { loggingMiddlewareBody, type ProcedureLogEntry, type ProcedureLogger } from './logging'; + +function createCapturingLogger(): { logger: ProcedureLogger; entries: ProcedureLogEntry[] } { + const entries: ProcedureLogEntry[] = []; + return { + entries, + logger: { + log(entry) { + entries.push(entry); + }, + }, + }; +} + +describe('loggingMiddlewareBody', () => { + it('logs a successful query and returns its value', async () => { + const { logger, entries } = createCapturingLogger(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger)); + const appRouter = router({ + greet: logged.query(() => 'hello'), + }); + + const caller = createCallerFactory(appRouter)({}); + await expect(caller.greet()).resolves.toBe('hello'); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ type: 'query', path: 'greet', ok: true, aborted: false }); + expect(entries[0].durationMs).toBeGreaterThanOrEqual(0); + expect(entries[0].error).toBeUndefined(); + }); + + it('logs a failed procedure with the error and re-throws to the caller', async () => { + const { logger, entries } = createCapturingLogger(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger)); + const appRouter = router({ + boom: logged.query(() => { + throw new Error('kaboom'); + }), + }); + + const caller = createCallerFactory(appRouter)({}); + await expect(caller.boom()).rejects.toThrow('kaboom'); + + expect(entries).toHaveLength(1); + expect(entries[0].ok).toBe(false); + expect(entries[0].error?.message).toBe('kaboom'); + }); + + it('marks the entry aborted when the context signal has fired', async () => { + const { logger, entries } = createCapturingLogger(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger)); + const appRouter = router({ + work: logged.query(() => 'done'), + }); + + const controller = new AbortController(); + controller.abort(); + const caller = createCallerFactory(appRouter)({ signal: controller.signal }); + await caller.work(); + + expect(entries).toHaveLength(1); + expect(entries[0].aborted).toBe(true); + }); +}); diff --git a/packages/vscode-ext-webview/src/host/middleware/logging.ts b/packages/vscode-ext-webview/src/host/middleware/logging.ts new file mode 100644 index 000000000..bfd3bba12 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/logging.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Logging middleware body and its pluggable sink (`ProcedureLogger`). + * + * This is the zero-dependency observability path: it times each procedure, + * detects cooperative cancellation, and hands a structured entry to a + * {@link ProcedureLogger}. The default {@link consoleProcedureLogger} prints to + * the console so the package works out of the box; production consumers pass + * their own logger. + */ + +import { + getInvocationSignal, + type MiddlewareResultLike, + type ProcedureErrorLike, + type ProcedureInvocation, + type ProcedureType, +} from './types'; + +/** One structured log entry describing a completed procedure invocation. */ +export interface ProcedureLogEntry { + /** The operation kind. */ + type: ProcedureType; + /** The dotted procedure path, e.g. `collectionView.find`. */ + path: string; + /** Wall-clock duration of the invocation in milliseconds. */ + durationMs: number; + /** True when the procedure completed without error. */ + ok: boolean; + /** True when the operation's `AbortSignal` had fired. */ + aborted: boolean; + /** The error carried by a failed result, when `ok` is false. */ + error?: ProcedureErrorLike; + /** + * Number of concurrent in-flight operations (queries + mutations + + * subscriptions) at the moment this entry was logged, including the one being + * logged. Set by `attachTrpc`'s dispatch pump (R766-S04); omitted by the + * standalone {@link loggingMiddlewareBody}, which has no view of the + * transport's in-flight set. Route it to a telemetry distribution to observe + * peak / average concurrency and decide whether the per-operation `window` + * `message` listener ever needs a single-listener multiplexer. + */ + concurrent?: number; +} + +/** + * Pluggable sink for {@link loggingMiddlewareBody}. Implement this to route + * procedure log entries to your own logging channel. + */ +export interface ProcedureLogger { + log(entry: ProcedureLogEntry): void; +} + +/** + * Zero-config default logger. Prints a single line per procedure to the + * console. Replace it with your own {@link ProcedureLogger} in production. + */ +export const consoleProcedureLogger: ProcedureLogger = { + log(entry: ProcedureLogEntry): void { + const status = entry.aborted ? 'Canceled' : entry.ok ? 'OK' : 'Failed'; + // eslint-disable-next-line no-console -- this is the package's zero-config default sink + console.log( + `[tRPC] ${entry.type} ${entry.path} (${entry.durationMs}ms) ${status}`, + entry.error ? (entry.error.message ?? entry.error.name ?? '') : '', + ); + }, +}; + +/** + * Logging middleware body. Times the invocation, detects cancellation, and logs + * a structured entry, then returns the procedure's result unchanged. + * + * Wire it onto your own tRPC instance: + * + * ```ts + * const { publicProcedure } = initWebviewTrpc<RouterContext>(); + * const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, myLogger)); + * ``` + * + * @param invocation - the tRPC middleware options for this call. + * @param logger - sink for the log entry. Defaults to + * {@link consoleProcedureLogger}. + */ +export async function loggingMiddlewareBody<TResult extends MiddlewareResultLike>( + invocation: ProcedureInvocation<TResult>, + logger: ProcedureLogger = consoleProcedureLogger, +): Promise<TResult> { + const start = Date.now(); + const result = await invocation.next(); + const durationMs = Date.now() - start; + + logger.log({ + type: invocation.type, + path: invocation.path, + durationMs, + ok: result.ok, + aborted: getInvocationSignal(invocation.ctx)?.aborted ?? false, + error: result.ok ? undefined : result.error, + }); + + return result; +} diff --git a/packages/vscode-ext-webview/src/host/middleware/telemetry.test.ts b/packages/vscode-ext-webview/src/host/middleware/telemetry.test.ts new file mode 100644 index 000000000..56feae15a --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/telemetry.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type BaseRouterContext } from '../../shared/BaseRouterContext'; +import { initWebviewTrpc } from '../../shared/initWebviewTrpc'; +import { telemetryMiddlewareBody, type ProcedureTelemetry, type TelemetryRunner } from './telemetry'; + +function createCapturingRunner(): { + runner: TelemetryRunner; + bags: ProcedureTelemetry[]; + invocations: Array<{ type: string; path: string }>; +} { + const bags: ProcedureTelemetry[] = []; + const invocations: Array<{ type: string; path: string }> = []; + return { + bags, + invocations, + runner: { + run(invocation, execute) { + invocations.push({ type: invocation.type, path: invocation.path }); + const telemetry: ProcedureTelemetry = { properties: {}, measurements: {} }; + bags.push(telemetry); + return execute(telemetry); + }, + }, + }; +} + +describe('telemetryMiddlewareBody', () => { + it('runs through the runner, injects the telemetry bag into ctx, and records duration', async () => { + const { runner, bags, invocations } = createCapturingRunner(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); + const appRouter = router({ + touch: tracked.query(({ ctx }) => { + // The body injected the runner's bag as `ctx.telemetry`; the + // procedure can read and write it with no cast. + if (ctx.telemetry) { + ctx.telemetry.properties.touched = 'yes'; + } + return 'ok'; + }), + }); + + const caller = createCallerFactory(appRouter)({}); + await expect(caller.touch()).resolves.toBe('ok'); + + expect(invocations).toEqual([{ type: 'query', path: 'touch' }]); + expect(bags).toHaveLength(1); + expect(bags[0].properties.touched).toBe('yes'); + expect(bags[0].measurements.durationMs).toBeGreaterThanOrEqual(0); + expect(bags[0].properties.result).toBeUndefined(); + }); + + it('records a failure as Failed with the error name and message, then re-throws', async () => { + const { runner, bags } = createCapturingRunner(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); + const appRouter = router({ + boom: tracked.mutation(() => { + throw new Error('kaboom'); + }), + }); + + const caller = createCallerFactory(appRouter)({}); + await expect(caller.boom()).rejects.toThrow('kaboom'); + + expect(bags[0].properties.result).toBe('Failed'); + expect(bags[0].properties.error).toBeDefined(); + expect(bags[0].properties.errorMessage).toBe('kaboom'); + }); + + it('records an aborted invocation as Canceled and not Failed', async () => { + const { runner, bags } = createCapturingRunner(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); + const appRouter = router({ + work: tracked.query(() => 'done'), + }); + + const controller = new AbortController(); + controller.abort(); + const caller = createCallerFactory(appRouter)({ signal: controller.signal }); + await caller.work(); + + expect(bags[0].properties.aborted).toBe('true'); + expect(bags[0].properties.result).toBe('Canceled'); + }); + + it('does not stamp error name/message when an aborted invocation also fails [R766-C01]', async () => { + const { runner, bags } = createCapturingRunner(); + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + + const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, runner)); + const appRouter = router({ + boom: tracked.mutation(() => { + throw new Error('work cancelled'); + }), + }); + + const controller = new AbortController(); + controller.abort(); + const caller = createCallerFactory(appRouter)({ signal: controller.signal }); + await expect(caller.boom()).rejects.toThrow('work cancelled'); + + // An aborted call is recorded only as Canceled — no error* fields, so a + // cancellation is never mistaken for a failure on the error dimension. + expect(bags[0].properties.aborted).toBe('true'); + expect(bags[0].properties.result).toBe('Canceled'); + expect(bags[0].properties.error).toBeUndefined(); + expect(bags[0].properties.errorMessage).toBeUndefined(); + }); +}); diff --git a/packages/vscode-ext-webview/src/host/middleware/telemetry.ts b/packages/vscode-ext-webview/src/host/middleware/telemetry.ts new file mode 100644 index 000000000..5b28f4e8e --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/telemetry.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Telemetry middleware body and its adapter interface (`TelemetryRunner`). + * + * The body owns the reusable orchestration (timing, abort detection, populating + * standard result properties) and injects a per-call telemetry bag into the + * procedure context. The {@link TelemetryRunner} adapter, supplied by the + * consumer, owns the integration-specific scope, for example wrapping the call + * in `callWithTelemetryAndErrorHandling` from `@microsoft/vscode-azext-utils` + * and dispatching the populated bag to Application Insights. + * + * This is the instance-agnostic telemetry path: the body is wired onto the + * consumer's own tRPC instance and the runner is a plain object, so neither is + * tied to a particular `initWebviewTrpc` call. + */ + +import { getInvocationSignal, type MiddlewareResultLike, type ProcedureInvocation } from './types'; + +/** + * Per-call telemetry bag the body populates and the runner dispatches. Mirrors + * the structural shape of common telemetry contexts (e.g. `ITelemetryContext` + * from `@microsoft/vscode-azext-utils`). + */ +export interface ProcedureTelemetry { + properties: Record<string, string>; + measurements: Record<string, number>; +} + +/** + * Re-types the `telemetry` slot on a router context `TContext` from the package's + * minimal shape to a richer telemetry-library context `TTelemetry` (for example + * `ITelemetryContext` from `@microsoft/vscode-azext-utils`), and makes it + * required. + * + * The telemetry middleware injects whatever bag your {@link TelemetryRunner} + * hands it into `ctx.telemetry`. When that bag is richer than the package default + * `{ properties, measurements }`, annotate the procedure's `ctx` with + * `WithTelemetry<...>` so procedure code reads the extra fields (for example + * `suppressAll`) without an ad-hoc cast. + * + * @template TContext - the router context (must have an optional `telemetry`). + * @template TTelemetry - the concrete telemetry context type the runner supplies; + * defaults to the package's {@link ProcedureTelemetry}. + * + * @example + * ```ts + * import type { ITelemetryContext } from '@microsoft/vscode-azext-utils'; + * import type { WithTelemetry } from '@microsoft/vscode-ext-webview/host'; + * + * type Ctx = BaseRouterContext & { db: Db }; + * export type TrackedCtx = WithTelemetry<Ctx, ITelemetryContext>; + * + * publicProcedure.query(({ ctx }: { ctx: TrackedCtx }) => { + * ctx.telemetry.properties.result = 'ok'; // fully typed, no cast + * }); + * ``` + */ +export type WithTelemetry<TContext extends { telemetry?: unknown }, TTelemetry = ProcedureTelemetry> = Omit< + TContext, + 'telemetry' +> & { + telemetry: TTelemetry; +}; + +/** + * Consumer-supplied adapter that runs a procedure inside an integration-specific + * telemetry scope. + * + * The runner establishes the scope (creating or obtaining a telemetry bag), + * invokes `execute(bag)` exactly once, and returns its result. The package + * provides `execute`: it drives the procedure and records duration and outcome + * into `bag`. + * + * @example A runner over `@microsoft/vscode-azext-utils` + * ```ts + * const runner: TelemetryRunner = { + * async run(invocation, execute) { + * const result = await callWithTelemetryAndErrorHandling( + * `myExt.rpc.${invocation.type}.${invocation.path}`, + * async (context) => { + * context.errorHandling.suppressDisplay = true; + * return execute(context.telemetry); + * }, + * ); + * if (!result) throw new Error(`No result for ${invocation.type} ${invocation.path}`); + * return result; + * }, + * }; + * ``` + */ +export interface TelemetryRunner { + run<TResult extends MiddlewareResultLike>( + invocation: ProcedureInvocation<TResult>, + execute: (telemetry: ProcedureTelemetry) => Promise<TResult>, + ): Promise<TResult>; +} + +/** + * Telemetry middleware body. Delegates to the consumer's {@link TelemetryRunner} + * to establish a telemetry scope, then within it: injects the telemetry bag into + * the procedure context, times the call, records cancellation as + * `Canceled`/`aborted`, records failures as `Failed` with the error name and + * message, and returns the procedure's result unchanged. + * + * Wire it onto your own tRPC instance: + * + * ```ts + * const { publicProcedure } = initWebviewTrpc<RouterContext>(); + * const tracked = publicProcedure.use((opts) => telemetryMiddlewareBody(opts, myRunner)); + * ``` + * + * @param invocation - the tRPC middleware options for this call. + * @param runner - the consumer's telemetry scope adapter. + */ +export async function telemetryMiddlewareBody<TResult extends MiddlewareResultLike>( + invocation: ProcedureInvocation<TResult>, + runner: TelemetryRunner, +): Promise<TResult> { + return runner.run(invocation, async (telemetry) => { + const start = Date.now(); + const result = await invocation.next({ + ctx: { ...(invocation.ctx as Record<string, unknown>), telemetry }, + }); + telemetry.measurements.durationMs = Date.now() - start; + + const aborted = getInvocationSignal(invocation.ctx)?.aborted ?? false; + if (aborted) { + telemetry.properties.aborted = 'true'; + telemetry.properties.result = 'Canceled'; + } + + if (!result.ok && !aborted) { + // Record the failure and let the RPC caller handle the error. This + // block is skipped entirely for an aborted call: it is already + // recorded as 'Canceled' above, and stamping `error` / + // `errorMessage` on a cancellation would make it look like a + // failure in telemetry (R766-C01). + telemetry.properties.result = 'Failed'; + if (result.error?.name) { + telemetry.properties.error = result.error.name; + } + if (result.error?.message) { + telemetry.properties.errorMessage = result.error.message; + } + } + + return result; + }); +} diff --git a/packages/vscode-ext-webview/src/host/middleware/types.ts b/packages/vscode-ext-webview/src/host/middleware/types.ts new file mode 100644 index 000000000..6ac57ab33 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/middleware/types.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared types for the instance-agnostic middleware bodies. + * + * A "middleware body" is a plain async function holding reusable orchestration + * (timing, abort detection, result inspection). It is wired onto the consumer's + * own tRPC instance, e.g. + * + * ```ts + * publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger)); + * ``` + * + * Because the body is not bound to any particular tRPC instance, the same body + * works across consumers regardless of how they called {@link initWebviewTrpc}. + * The types here are the structural contract a real tRPC middleware `opts` + * object satisfies. + */ + +/** The kind of tRPC operation being invoked. */ +export type ProcedureType = 'query' | 'mutation' | 'subscription'; + +/** + * Structural view of an error carried by a failed middleware result. A tRPC + * `TRPCError` satisfies this shape. + */ +export interface ProcedureErrorLike { + name?: string; + message?: string; + code?: string | number; + cause?: unknown; +} + +/** + * Structural shape of the value a tRPC middleware's `next()` resolves to. The + * bodies only read `ok` (and `error` on failure), so this minimal shape keeps + * them decoupled from tRPC's internal result type while staying assignable from + * it. + */ +export interface MiddlewareResultLike { + readonly ok: boolean; + readonly error?: ProcedureErrorLike; +} + +/** + * Structural subset of a tRPC middleware's options that the middleware bodies + * consume. A real tRPC middleware `opts` object satisfies this, so a body can + * be wired as `publicProcedure.use((opts) => body(opts, adapter))`. + * + * @template TResult - The exact result type `next()` resolves to. Bodies are + * generic over it and pass it straight through, so wiring a + * body into `.use()` preserves tRPC's own result typing. + */ +export interface ProcedureInvocation<TResult extends MiddlewareResultLike = MiddlewareResultLike> { + readonly type: ProcedureType; + readonly path: string; + readonly ctx: unknown; + next: (opts?: { ctx?: unknown }) => Promise<TResult>; +} + +/** + * Reads the cooperative `AbortSignal` off a router context without assuming the + * concrete context type. Returns `undefined` when the context carries no signal. + */ +export function getInvocationSignal(ctx: unknown): AbortSignal | undefined { + return (ctx as { signal?: AbortSignal } | null | undefined)?.signal; +} diff --git a/packages/vscode-ext-webview/src/host/openWebview.test.ts b/packages/vscode-ext-webview/src/host/openWebview.test.ts new file mode 100644 index 000000000..62a8a0529 --- /dev/null +++ b/packages/vscode-ext-webview/src/host/openWebview.test.ts @@ -0,0 +1,260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { type BaseRouterContext } from '../shared/BaseRouterContext'; +import { initWebviewTrpc } from '../shared/initWebviewTrpc'; +import { type VsCodeLinkRequestMessage } from '../shared/wireProtocol'; +import { openWebview } from './openWebview'; +import { WebviewController } from './WebviewController'; + +const sourceLayout = { + bundled: { dir: 'dist', file: 'views.js' }, + dev: { dir: 'out', file: 'views.js' }, +}; + +/** Lets queued microtasks settle. */ +const flush = (): Promise<void> => new Promise((resolve) => setImmediate(resolve)); + +function makeContext(): vscode.ExtensionContext { + return { + extensionPath: '/ext', + extensionMode: vscode.ExtensionMode.Production, + } as unknown as vscode.ExtensionContext; +} + +/** Builds the factory options (minus `extensionContext`) with a tiny router. */ +function makeOptions() { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<BaseRouterContext>(); + const appRouter = router({ + greet: publicProcedure.query(() => 'hi'), + }); + return { + title: 'My View', + viewType: 'myView', + router: appRouter, + createCallerFactory, + context: {} as BaseRouterContext, + config: { hello: 'world' }, + sourceLayout, + isBundled: true, + }; +} + +/** Reaches past the real `vscode` types into the mock panel internals. */ +function mockWebview(controller: { panel: { webview: unknown } }): { + posted: unknown[]; + receive(message: unknown): void; +} { + return controller.panel.webview as unknown as { posted: unknown[]; receive(message: unknown): void }; +} + +describe('openWebview', () => { + it('opens a panel and returns a WebviewController handle', () => { + const controller = openWebview(makeContext(), makeOptions()); + + expect(controller).toBeInstanceOf(WebviewController); + expect(controller.panel).toBeDefined(); + expect(controller.isDisposed).toBe(false); + expect(controller.onDisposed).toBeDefined(); + expect(typeof controller.revealToForeground).toBe('function'); + expect(typeof controller.dispose).toBe('function'); + }); + + it('renders the configuration and viewType into the panel HTML', () => { + const controller = openWebview(makeContext(), makeOptions()); + + const html = controller.panel.webview.html; + // R766-N03: initial data (including viewType) is delivered in an inert + // application/json block; the boot script parses it and calls render(...). + expect(html).toContain('id="vscode-ext-webview-initial-data"'); + expect(html).toContain('"viewType":"myView"'); + expect(html).toContain('render(__data.viewType'); + expect(html).toContain(encodeURIComponent(JSON.stringify({ hello: 'world' }))); + }); + + it('escapes `</script>` in the serialized initial-data block so it cannot break out (R766-N03)', () => { + const controller = openWebview(makeContext(), { + ...makeOptions(), + viewType: '</script><script>alert(1)</script>', + }); + const html = controller.panel.webview.html; + + // The injected `<` must be escaped as \u003c inside the inert JSON block, + // so the raw break-out sequence never appears in the document. + expect(html).not.toContain('<script>alert(1)'); + expect(html).toContain('\\u003c/script'); + }); + + it('reveals the panel via revealToForeground', () => { + const controller = openWebview(makeContext(), makeOptions()); + const panel = controller.panel as unknown as { revealCount: number }; + + controller.revealToForeground(); + + expect(panel.revealCount).toBe(1); + }); + + it('fires onDisposed and flips isDisposed on dispose', () => { + const controller = openWebview(makeContext(), makeOptions()); + let fired = false; + controller.onDisposed(() => { + fired = true; + }); + + controller.dispose(); + + expect(controller.isDisposed).toBe(true); + expect(fired).toBe(true); + }); + + it('closes the panel when dispose() is called (R766-02)', () => { + const controller = openWebview(makeContext(), makeOptions()); + const panel = controller.panel as unknown as { disposed: boolean }; + + controller.dispose(); + + // A public handle whose dispose() left the tab open would be surprising; + // dispose() must close the panel. + expect(panel.disposed).toBe(true); + expect(controller.isDisposed).toBe(true); + }); + + it('disposes the controller exactly once when the user closes the tab (R766-02)', () => { + const controller = openWebview(makeContext(), makeOptions()); + const panel = controller.panel as unknown as { disposed: boolean }; + let firedCount = 0; + controller.onDisposed(() => { + firedCount += 1; + }); + + // Simulate the user closing the tab: VS Code disposes the panel, firing + // onDidDispose. The controller must dispose exactly once and must not + // recurse back into panel.dispose(). + controller.panel.dispose(); + + expect(controller.isDisposed).toBe(true); + expect(panel.disposed).toBe(true); + expect(firedCount).toBe(1); + }); + + it('auto-wires tRPC so the panel answers a query', async () => { + const controller = openWebview(makeContext(), makeOptions()); + const webview = mockWebview(controller); + + const message: VsCodeLinkRequestMessage = { + id: 'q1', + op: { + id: 0, + type: 'query', + path: 'greet', + input: undefined, + context: {}, + } as VsCodeLinkRequestMessage['op'], + }; + webview.receive(message); + await flush(); + + expect(webview.posted).toContainEqual({ id: 'q1', result: 'hi' }); + }); + + it('wires the caller factory from the `trpc` instance option (R766-N02)', async () => { + // Pass the whole `initWebviewTrpc()` result via `trpc` and NO standalone + // `createCallerFactory`. The controller must read `trpc.createCallerFactory` + // off it, so the query dispatches against the same instance the router was + // built from. + const trpc = initWebviewTrpc<BaseRouterContext>(); + const appRouter = trpc.router({ + greet: trpc.publicProcedure.query(() => 'hi from trpc'), + }); + const controller = openWebview(makeContext(), { + title: 'My View', + viewType: 'myView', + router: appRouter, + trpc, + context: {} as BaseRouterContext, + config: { hello: 'world' }, + sourceLayout, + isBundled: true, + }); + const webview = mockWebview(controller); + + webview.receive({ + id: 'q1', + op: { id: 0, type: 'query', path: 'greet', input: undefined, context: {} }, + } as VsCodeLinkRequestMessage); + await flush(); + + expect(webview.posted).toContainEqual({ id: 'q1', result: 'hi from trpc' }); + }); +}); + +describe('new WebviewController(options)', () => { + it('accepts the options bag directly', () => { + const controller = new WebviewController({ extensionContext: makeContext(), ...makeOptions() }); + + expect(controller).toBeInstanceOf(WebviewController); + expect(controller.isDisposed).toBe(false); + expect(controller.panel.webview.html).toContain('id="vscode-ext-webview-initial-data"'); + expect(controller.panel.webview.html).toContain('"viewType":"myView"'); + }); +}); + +describe('script source layout selection', () => { + // Distinct file names so the selected layout is unambiguous in the HTML. + const splitLayout = { + bundled: { dir: 'dist', file: 'views.js' }, + dev: { dir: 'out', file: 'index.js' }, + }; + const devServerHost = 'http://localhost:18080'; + + function makeContextWithMode(mode: number): vscode.ExtensionContext { + return { + extensionPath: '/ext', + extensionMode: mode, + } as unknown as vscode.ExtensionContext; + } + + function htmlFor(overrides: { mode: number; isBundled: boolean; devServer: boolean }): string { + const previous = process.env.DEVSERVER; + if (overrides.devServer) { + process.env.DEVSERVER = 'true'; + } else { + delete process.env.DEVSERVER; + } + try { + const controller = openWebview(makeContextWithMode(overrides.mode), { + ...makeOptions(), + sourceLayout: splitLayout, + devServerHost, + isBundled: overrides.isBundled, + }); + return controller.panel.webview.html; + } finally { + if (previous === undefined) { + delete process.env.DEVSERVER; + } else { + process.env.DEVSERVER = previous; + } + } + } + + it('loads the bundled file from the dev server when bundled in development (regression: index.js 404)', () => { + // A webpack-bundled extension running in Development with the dev server + // on must ask the dev server for the *bundled* asset name (views.js), + // because `webpack serve` emits that name. Keying the layout off the mode + // alone would request the tsc `index.js` and 404. + const html = htmlFor({ mode: vscode.ExtensionMode.Development, isBundled: true, devServer: true }); + + expect(html).toContain(`import { render } from "${devServerHost}/views.js"`); + expect(html).not.toContain(`${devServerHost}/index.js`); + }); + + it('loads the dev (tsc) file from the dev server when not bundled', () => { + const html = htmlFor({ mode: vscode.ExtensionMode.Development, isBundled: false, devServer: true }); + + expect(html).toContain(`import { render } from "${devServerHost}/index.js"`); + }); +}); diff --git a/packages/vscode-ext-webview/src/host/openWebview.ts b/packages/vscode-ext-webview/src/host/openWebview.ts new file mode 100644 index 000000000..b4e81189b --- /dev/null +++ b/packages/vscode-ext-webview/src/host/openWebview.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AnyRouter } from '@trpc/server'; +import type * as vscode from 'vscode'; +import { type BaseRouterContext } from '../shared/BaseRouterContext'; +import { WebviewController, type WebviewControllerOptions } from './WebviewController'; + +/** + * The greenfield front door: opens a webview panel and returns its + * {@link WebviewController} handle. + * + * This is sugar over `new WebviewController({ extensionContext, ...options })`. + * The returned controller owns the panel, renders the HTML, and wires the tRPC + * dispatch pump (with the default console logger unless `options.logger` is + * supplied). Use it directly for the common case; subclass + * {@link WebviewController} when you need lifecycle hooks or extra methods. + * + * @template TRouter - The application's root tRPC router type. + * @template TConfiguration - The configuration object delivered to the webview. + * @template TContext - The router context shape (must extend {@link BaseRouterContext}). + * + * @param extensionContext - The extension context. + * @param options - Everything else (see {@link WebviewControllerOptions}). + * @returns The {@link WebviewController} handle, exposing `panel`, `onDisposed`, + * `revealToForeground`, `dispose`, and `isDisposed`. + * + * @example + * ```ts + * const controller = openWebview(context, { + * title: 'My View', + * viewType: 'myView', + * router: appRouter, + * trpc, + * context: { ...myContext }, + * config: { ...initialConfig }, + * sourceLayout: { bundled: { dir: 'dist', file: 'views.js' }, dev: { dir: 'out', file: 'views.js' } }, + * isBundled: !!process.env.IS_BUNDLE, + * }); + * ``` + */ +export function openWebview< + TRouter extends AnyRouter, + TConfiguration = unknown, + TContext extends BaseRouterContext = BaseRouterContext, +>( + extensionContext: vscode.ExtensionContext, + options: Omit<WebviewControllerOptions<TRouter, TConfiguration, TContext>, 'extensionContext'>, +): WebviewController<TRouter, TConfiguration, TContext> { + return new WebviewController<TRouter, TConfiguration, TContext>({ extensionContext, ...options }); +} diff --git a/packages/vscode-ext-webview/src/index.ts b/packages/vscode-ext-webview/src/index.ts new file mode 100644 index 000000000..733736dca --- /dev/null +++ b/packages/vscode-ext-webview/src/index.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared entry point (`.`) for `@microsoft/vscode-ext-webview`. + * + * This is the side-agnostic surface: wire-protocol message types, + * `TypedEventSink`, and `BaseRouterContext`. It imports neither `vscode` nor + * React, so it is safe to import from either side of the transport. + * + * Extension-host code imports from the `./host` subpath; webview code imports + * from `./webview` (framework-agnostic) or `./react` (React hooks). + */ + +export * from './shared/index'; diff --git a/packages/vscode-ext-webview/src/react.ts b/packages/vscode-ext-webview/src/react.ts new file mode 100644 index 000000000..d6fb77990 --- /dev/null +++ b/packages/vscode-ext-webview/src/react.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Entry point for the React surface of `@microsoft/vscode-ext-webview` (the + * `./react` subpath). + * + * The only entry that imports React. Provides `useTrpcClient`, + * `useConfiguration`, and `WithWebviewContext` for React webviews on top of the + * framework-agnostic `./webview` transport. + */ + +export * from './react/index'; diff --git a/packages/vscode-ext-webview/src/react/README.md b/packages/vscode-ext-webview/src/react/README.md new file mode 100644 index 000000000..08f233e61 --- /dev/null +++ b/packages/vscode-ext-webview/src/react/README.md @@ -0,0 +1,23 @@ +# react + +React bindings for the webview. This is the only folder that imports `react`, +and it builds on `webview/`. + +Import path: + +```ts +import { ... } from '@microsoft/vscode-ext-webview/react'; +``` + +Contents: + +- `WebviewContext.tsx`: `WithWebviewContext`, the provider you wrap your app in + once. +- `useTrpcClient.ts`: returns the tRPC client. +- `useRpcEvents.ts`: returns the shared event channel. +- `useConfiguration.ts`: reads the initial configuration passed at panel + creation. +- `connection.ts`: internal wiring that gives the hooks one shared client and + event channel per webview. + +If you do not use React, use `./webview` directly. diff --git a/packages/vscode-ext-webview/src/react/WebviewContext.tsx b/packages/vscode-ext-webview/src/react/WebviewContext.tsx new file mode 100644 index 000000000..9ed33e4b8 --- /dev/null +++ b/packages/vscode-ext-webview/src/react/WebviewContext.tsx @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as React from 'react'; +import { createContext } from 'react'; +import { type WebviewApi } from 'vscode-webview'; +import { type ObserverErrorHandler } from '../webview/events'; + +export type WebviewState = object; + +export type WebviewContextValue = { + vscodeApi: WebviewApi<WebviewState>; + /** + * When `true`, the shared tRPC client logs every call to the webview devtools + * console (tRPC's `loggerLink`). Off by default; set it on + * {@link WithWebviewContext} to enable it for the whole webview. + */ + enableRpcLogging?: boolean; + /** + * Called when an RPC event-channel observer throws. The channel always + * isolates the throw (a broken observer cannot break the call it observes); + * this only routes the isolated error. Defaults to `console.error`. Set it on + * {@link WithWebviewContext} to route observer failures to telemetry. + */ + onObserverError?: ObserverErrorHandler; +}; + +export const WebviewContext = createContext<WebviewContextValue>({} as WebviewContextValue); + +export const WithWebviewContext = ({ + vscodeApi, + enableRpcLogging, + onObserverError, + children, +}: { + vscodeApi: WebviewApi<WebviewState>; + enableRpcLogging?: boolean; + onObserverError?: ObserverErrorHandler; + children: React.ReactNode; +}) => { + return ( + <WebviewContext.Provider value={{ vscodeApi, enableRpcLogging, onObserverError }}> + {children} + </WebviewContext.Provider> + ); +}; diff --git a/packages/vscode-ext-webview/src/react/connection.test.ts b/packages/vscode-ext-webview/src/react/connection.test.ts new file mode 100644 index 000000000..349428cf5 --- /dev/null +++ b/packages/vscode-ext-webview/src/react/connection.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type VsCodeApiLike } from '../webview/connectTrpc'; +import { getWebviewConnection } from './connection'; + +/** + * Build a minimal fake `vscodeApi`. Each call returns a distinct object so it + * stands in for a separate webview. + */ +function makeApi(): VsCodeApiLike { + return { postMessage: () => {} }; +} + +describe('getWebviewConnection', () => { + it('returns one shared { client, events } instance per vscodeApi', () => { + const api = makeApi(); + + const first = getWebviewConnection(api); + const second = getWebviewConnection(api); + + // Both hooks resolve through this helper, so client and events must come + // from the very same connection for a given webview. + expect(second).toBe(first); + expect(second.client).toBe(first.client); + expect(second.events).toBe(first.events); + }); + + it('gives different webviews (distinct vscodeApi) independent connections', () => { + const first = getWebviewConnection(makeApi()); + const second = getWebviewConnection(makeApi()); + + expect(second).not.toBe(first); + expect(second.client).not.toBe(first.client); + expect(second.events).not.toBe(first.events); + }); +}); diff --git a/packages/vscode-ext-webview/src/react/connection.ts b/packages/vscode-ext-webview/src/react/connection.ts new file mode 100644 index 000000000..48efc26cd --- /dev/null +++ b/packages/vscode-ext-webview/src/react/connection.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Per-webview connection memo shared by the React hooks. + * + * A webview has exactly one `vscodeApi` (from `acquireVsCodeApi()`), and both + * {@link useTrpcClient} and {@link useRpcEvents} must hand back parts of the + * *same* {@link ConnectTrpcResult} so observers on the events channel see the + * outcomes of calls made through the client. This module keeps a single + * `connectTrpc` result per `vscodeApi`, keyed in a `WeakMap` so it is released + * when the api object is. + * + * This file is intentionally React-free; the hooks read `vscodeApi` from context + * and delegate here. + */ + +import { type AnyRouter } from '@trpc/server'; +import { + connectTrpc, + type ConnectTrpcOptions, + type ConnectTrpcResult, + type VsCodeApiLike, +} from '../webview/connectTrpc'; + +const connections = new WeakMap<VsCodeApiLike, ConnectTrpcResult<AnyRouter>>(); + +/** + * Return the shared {@link ConnectTrpcResult} for `vscodeApi`, creating it on + * first use. Repeated calls with the same `vscodeApi` return the identical + * `{ client, events }` instance. + * + * `options` are applied only on first creation (the connection is memoized per + * `vscodeApi`); later calls ignore them and return the cached pair. + * + * @template TRouter - The application's root tRPC router type. + */ +export function getWebviewConnection<TRouter extends AnyRouter>( + vscodeApi: VsCodeApiLike, + options?: ConnectTrpcOptions, +): ConnectTrpcResult<TRouter> { + let connection = connections.get(vscodeApi); + if (!connection) { + connection = connectTrpc<AnyRouter>(vscodeApi, options); + connections.set(vscodeApi, connection); + } + return connection as ConnectTrpcResult<TRouter>; +} diff --git a/packages/vscode-ext-webview/src/react/index.ts b/packages/vscode-ext-webview/src/react/index.ts new file mode 100644 index 000000000..c4bf45cec --- /dev/null +++ b/packages/vscode-ext-webview/src/react/index.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * React surface (the `./react` subpath). + * + * The only entry that imports React. Provides the hooks and context wiring a + * React webview needs (`useTrpcClient`, `useConfiguration`, `WithWebviewContext`) + * on top of the framework-agnostic `./webview` transport. Reshaped in Phase C to + * split `useRpcEvents` out of `useTrpcClient`. + */ + +export { type ObserverErrorContext, type ObserverErrorHandler, type ObserverErrorPhase } from '../webview/events'; +export { useConfiguration } from './useConfiguration'; +export { useRpcEvents } from './useRpcEvents'; +export { useTrpcClient, type TrpcClient } from './useTrpcClient'; +export { WebviewContext, WithWebviewContext, type WebviewContextValue, type WebviewState } from './WebviewContext'; diff --git a/packages/vscode-ext-react-webview/src/webview-client/useConfiguration.ts b/packages/vscode-ext-webview/src/react/useConfiguration.ts similarity index 59% rename from packages/vscode-ext-react-webview/src/webview-client/useConfiguration.ts rename to packages/vscode-ext-webview/src/react/useConfiguration.ts index bbd6ba77f..83cdfa377 100644 --- a/packages/vscode-ext-react-webview/src/webview-client/useConfiguration.ts +++ b/packages/vscode-ext-webview/src/react/useConfiguration.ts @@ -22,8 +22,17 @@ declare global { */ export function useConfiguration<T>(): T { const [configuration] = useState<T>(() => { - const configString = decodeURIComponent(window.config?.__initialData ?? '{}'); - return JSON.parse(configString) as T; + try { + const configString = decodeURIComponent(window.config?.__initialData ?? '{}'); + return JSON.parse(configString) as T; + } catch (error) { + // A malformed or missing `__initialData` payload must not crash the + // webview on first render (which would white-screen the view). Fall back + // to an empty config and log so the problem is diagnosable in the webview + // devtools console. + console.error('[vscode-ext-webview] Failed to parse webview configuration; using empty config.', error); + return {} as T; + } }); return configuration; diff --git a/packages/vscode-ext-webview/src/react/useRpcEvents.ts b/packages/vscode-ext-webview/src/react/useRpcEvents.ts new file mode 100644 index 000000000..169392aa7 --- /dev/null +++ b/packages/vscode-ext-webview/src/react/useRpcEvents.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext } from 'react'; +import { type RpcEventChannel } from '../webview/events'; +import { getWebviewConnection } from './connection'; +import { WebviewContext } from './WebviewContext'; + +/** + * React hook returning the webview's RPC event channel. + * + * The channel is the observe-only side of the per-webview connection shared + * with {@link useTrpcClient}: it reports the outcome of every query and + * mutation made through that client. Subscribe with `onSuccess`, `onError`, + * or `onAborted` to react to results webview-wide without wrapping individual + * call sites. The returned reference is stable across re-renders. + * + * Subscription procedures are intentionally excluded from the channel; observe + * their results through the per-subscription `.subscribe({ ... })` callbacks. + * + * @returns The webview's {@link RpcEventChannel}. + * + * @example + * ```tsx + * import { useEffect } from 'react'; + * import { useRpcEvents } from '@microsoft/vscode-ext-webview/react'; + * + * export const ErrorAnnouncer = () => { + * const events = useRpcEvents(); + * + * useEffect(() => events.onError((error) => announcer.announceError(error.message)), [events]); + * + * return <></>; + * }; + * ``` + */ +export function useRpcEvents(): RpcEventChannel { + const { vscodeApi, enableRpcLogging, onObserverError } = useContext(WebviewContext); + return getWebviewConnection(vscodeApi, { logger: enableRpcLogging, onObserverError }).events; +} diff --git a/packages/vscode-ext-webview/src/react/useTrpcClient.ts b/packages/vscode-ext-webview/src/react/useTrpcClient.ts new file mode 100644 index 000000000..8476399b6 --- /dev/null +++ b/packages/vscode-ext-webview/src/react/useTrpcClient.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CreateTRPCClient } from '@trpc/client'; +import { type AnyRouter } from '@trpc/server'; +import { useContext } from 'react'; +import { getWebviewConnection } from './connection'; +import { WebviewContext } from './WebviewContext'; + +/** + * Convenience alias for a fully-typed tRPC client for a given application router. + * + * @example + * ```ts + * import type { AppRouter } from './appRouter'; + * import type { TrpcClient } from '@microsoft/vscode-ext-webview/react'; + * + * type AppTrpcClient = TrpcClient<AppRouter>; + * ``` + */ +export type TrpcClient<TRouter extends AnyRouter> = CreateTRPCClient<TRouter>; + +/** + * React hook returning the tRPC client for talking to the extension host. + * + * The client is a per-webview singleton: every component that calls this hook + * (and {@link useRpcEvents}) shares the same underlying connection, so events + * observed on the channel correspond to calls made through this client. The + * returned reference is stable across re-renders. + * + * For webview-wide observation of query/mutation outcomes (errors, aborts, + * successes), subscribe through {@link useRpcEvents} rather than wrapping every + * call site. + * + * @template TRouter - The application's root tRPC router type. + * @returns The tRPC client. + * + * @example + * ```tsx + * import { useTrpcClient } from '@microsoft/vscode-ext-webview/react'; + * import type { AppRouter } from '../_integration/appRouter'; + * + * export const MyComponent = () => { + * const trpcClient = useTrpcClient<AppRouter>(); + * + * useEffect(() => { + * void trpcClient.myProcedure.query().then((result) => { + * console.log('Procedure result:', result); + * }); + * }, [trpcClient]); + * + * return <></>; + * }; + * ``` + */ +export function useTrpcClient<TRouter extends AnyRouter>(): TrpcClient<TRouter> { + const { vscodeApi, enableRpcLogging, onObserverError } = useContext(WebviewContext); + return getWebviewConnection<TRouter>(vscodeApi, { logger: enableRpcLogging, onObserverError }).client; +} diff --git a/packages/vscode-ext-react-webview/src/extension-server/BaseRouterContext.ts b/packages/vscode-ext-webview/src/shared/BaseRouterContext.ts similarity index 64% rename from packages/vscode-ext-react-webview/src/extension-server/BaseRouterContext.ts rename to packages/vscode-ext-webview/src/shared/BaseRouterContext.ts index b58f87225..ec8991790 100644 --- a/packages/vscode-ext-react-webview/src/extension-server/BaseRouterContext.ts +++ b/packages/vscode-ext-webview/src/shared/BaseRouterContext.ts @@ -3,24 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type TelemetryContext } from './trpc'; - /** * Base router context shared by every tRPC procedure invocation. Consumers * extend this with their own application-specific properties (e.g. database * connection identifiers, view-specific data). * * The framework populates {@link BaseRouterContext.signal} per-operation and - * {@link BaseRouterContext.telemetry} (when the corresponding middleware is - * used). Application code is responsible for the rest. + * the telemetry middleware body (when wired) populates + * {@link BaseRouterContext.telemetry}. Application code is responsible for the + * rest. */ export interface BaseRouterContext { /** - * Telemetry context populated by the telemetry middleware (e.g. - * `publicProcedureWithTelemetry` or your own custom middleware). - * Available when using a procedure that applies a telemetry middleware. + * Per-call telemetry bag, populated by the telemetry middleware body when + * one is wired (see `telemetryMiddlewareBody` / `TelemetryRunner` in + * `@microsoft/vscode-ext-webview/host`). + * + * The package does not dictate the telemetry context type: this slot holds + * a minimal `properties` / `measurements` shape, and consumers typically + * re-type it to their telemetry library's context (for example + * `ITelemetryContext` from `@microsoft/vscode-azext-utils`) via an + * intersection or a telemetry-typing helper they own. */ - telemetry?: TelemetryContext; + telemetry?: { + properties: Record<string, string>; + measurements: Record<string, number>; + }; /** * AbortSignal used to cancel in-flight operations (queries, mutations, and @@ -37,7 +45,7 @@ export interface BaseRouterContext { * * ```ts * .query(async ({ ctx }) => { - * // Option 1: Pass to APIs that accept AbortSignal (e.g. MongoDB driver) + * // Option 1: Pass to APIs that accept AbortSignal (e.g. the DocumentDB driver) * const cursor = collection.find(filter, { signal: ctx.signal }); * // Option 2: Check manually * if (ctx.signal?.aborted) return; diff --git a/packages/vscode-ext-webview/src/shared/README.md b/packages/vscode-ext-webview/src/shared/README.md new file mode 100644 index 000000000..0f9c7e523 --- /dev/null +++ b/packages/vscode-ext-webview/src/shared/README.md @@ -0,0 +1,23 @@ +# shared + +Side-agnostic contracts shared by the extension host and the webview. This code +runs in both Node and the browser, so it imports neither `vscode` nor `react`. + +Import path (package root): + +```ts +import { ... } from '@microsoft/vscode-ext-webview'; +``` + +Contents: + +- `wireProtocol.ts`: the `postMessage` message types (the host and webview + contract). +- `TypedEventSink.ts`: bridges push events from the host into a tRPC + subscription. +- `BaseRouterContext.ts`: the tRPC context contract (`signal`, `telemetry`). +- `initWebviewTrpc.ts`: typed tRPC root (`router`, `publicProcedure`, + `createCallerFactory`) bound to your context type. + +Rule for contributors and agents: do not import `vscode` or `react` here. A +symbol that needs either belongs in `host/` or `react/`. diff --git a/packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.test.ts b/packages/vscode-ext-webview/src/shared/TypedEventSink.test.ts similarity index 100% rename from packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.test.ts rename to packages/vscode-ext-webview/src/shared/TypedEventSink.test.ts diff --git a/packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.ts b/packages/vscode-ext-webview/src/shared/TypedEventSink.ts similarity index 100% rename from packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.ts rename to packages/vscode-ext-webview/src/shared/TypedEventSink.ts diff --git a/packages/vscode-ext-webview/src/shared/index.ts b/packages/vscode-ext-webview/src/shared/index.ts new file mode 100644 index 000000000..a467d58d9 --- /dev/null +++ b/packages/vscode-ext-webview/src/shared/index.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared, side-agnostic surface of `@microsoft/vscode-ext-webview`. + * + * Everything re-exported here is safe to import from either the extension host + * or the webview: there are no `vscode` and no React imports anywhere in the + * `shared/` subtree. The host (`./host`) and webview (`./webview`) entries + * build on top of these primitives. + */ + +export { type BaseRouterContext } from './BaseRouterContext'; +export { initWebviewTrpc, publicProcedure, router, type WebviewTrpc } from './initWebviewTrpc'; +export { TypedEventSink, type DiscriminatedEvent, type EventOfType, type UntypedEventEmitter } from './TypedEventSink'; +export { type StopOperation, type VsCodeLinkRequestMessage, type VsCodeLinkResponseMessage } from './wireProtocol'; diff --git a/packages/vscode-ext-webview/src/shared/initWebviewTrpc.test.ts b/packages/vscode-ext-webview/src/shared/initWebviewTrpc.test.ts new file mode 100644 index 000000000..062909017 --- /dev/null +++ b/packages/vscode-ext-webview/src/shared/initWebviewTrpc.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type BaseRouterContext } from './BaseRouterContext'; +import { initWebviewTrpc } from './initWebviewTrpc'; + +type TestContext = BaseRouterContext & { + workspaceRoot: string; + requestCount: number; +}; + +describe('initWebviewTrpc', () => { + it('infers the consumer context type inside procedures with no cast', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<TestContext>(); + + const appRouter = router({ + // `ctx.workspaceRoot` and `ctx.requestCount` are read directly off + // `ctx` with NO `ctx as TestContext` cast. If `initWebviewTrpc` did + // not bind the context type, these lines would not type-check and + // ts-jest would fail to compile this test. + cwd: publicProcedure.query(({ ctx }) => ctx.workspaceRoot), + count: publicProcedure.query(({ ctx }) => ctx.requestCount + 1), + }); + + const caller = createCallerFactory(appRouter)({ + workspaceRoot: '/repo', + requestCount: 41, + }); + + await expect(caller.cwd()).resolves.toBe('/repo'); + await expect(caller.count()).resolves.toBe(42); + }); + + it('passes the AbortSignal from BaseRouterContext through to procedures', async () => { + const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<TestContext>(); + + const appRouter = router({ + aborted: publicProcedure.query(({ ctx }) => ctx.signal?.aborted ?? false), + }); + + const controller = new AbortController(); + controller.abort(); + + const caller = createCallerFactory(appRouter)({ + workspaceRoot: '/repo', + requestCount: 0, + signal: controller.signal, + }); + + await expect(caller.aborted()).resolves.toBe(true); + }); + + it('returns independent instances on each call', () => { + const a = initWebviewTrpc<TestContext>(); + const b = initWebviewTrpc<TestContext>(); + + expect(a.router).not.toBe(b.router); + expect(a.publicProcedure).not.toBe(b.publicProcedure); + }); +}); diff --git a/packages/vscode-ext-webview/src/shared/initWebviewTrpc.ts b/packages/vscode-ext-webview/src/shared/initWebviewTrpc.ts new file mode 100644 index 000000000..fda1e6898 --- /dev/null +++ b/packages/vscode-ext-webview/src/shared/initWebviewTrpc.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Typed tRPC initialiser for webview routers. + * + * `initTRPC` should be called once per application. Calling + * `initWebviewTrpc<TContext>()` binds the tRPC root to the consumer's context + * type, so every procedure built from the returned `publicProcedure` sees + * `ctx` typed as `TContext` with **no `ctx as RouterContext` cast**. + * + * @see https://trpc.io/docs/server/routers + * @see https://trpc.io/docs/server/context + * + * @example + * ```ts + * import { initWebviewTrpc, type BaseRouterContext } from '@microsoft/vscode-ext-webview'; + * + * type RouterContext = BaseRouterContext & { workspaceRoot: string }; + * + * const { router, publicProcedure, createCallerFactory } = initWebviewTrpc<RouterContext>(); + * + * export const appRouter = router({ + * // `ctx.workspaceRoot` is typed; no cast needed. + * cwd: publicProcedure.query(({ ctx }) => ctx.workspaceRoot), + * }); + * ``` + */ + +import { initTRPC } from '@trpc/server'; +import { type BaseRouterContext } from './BaseRouterContext'; + +/** + * The set of tRPC builders returned by {@link initWebviewTrpc}, all bound to the + * consumer's `TContext`. + * + * - `router` builds (sub)routers; + * - `publicProcedure` is the base procedure (`ctx` is typed as `TContext`); + * - `createCallerFactory` builds a server-side caller for a router (used by + * the host dispatcher to invoke procedures); + * - `middleware` builds reusable middleware bound to this instance. + */ +export type WebviewTrpc<TContext extends BaseRouterContext> = ReturnType<typeof initWebviewTrpc<TContext>>; + +/** + * Create a context-typed tRPC root for a webview application. + * + * @template TContext - The router context shape (must extend + * {@link BaseRouterContext}). Defaults to + * `BaseRouterContext`. + */ +export function initWebviewTrpc<TContext extends BaseRouterContext = BaseRouterContext>() { + const t = initTRPC.context<TContext>().create(); + + return { + router: t.router, + publicProcedure: t.procedure, + createCallerFactory: t.createCallerFactory, + middleware: t.middleware, + }; +} + +/** + * Default tRPC instance backing the convenience re-exports below. + * + * Its context is the bare {@link BaseRouterContext}. The same instance also + * provides the default `createCallerFactory` used by the host dispatcher when a + * consumer does not pass one, so the default `router` / `publicProcedure` and + * that default caller factory always belong to a single tRPC instance. + */ +const defaultTrpc = initWebviewTrpc(); + +/** Convenience `router` builder bound to the default {@link BaseRouterContext}. */ +export const router = defaultTrpc.router; + +/** Convenience base procedure bound to the default {@link BaseRouterContext}. */ +export const publicProcedure = defaultTrpc.publicProcedure; + +/** + * Caller factory for the default instance. The host dispatcher uses this when a + * consumer does not supply its own `createCallerFactory`. + */ +export const createCallerFactory = defaultTrpc.createCallerFactory; diff --git a/packages/vscode-ext-webview/src/shared/wireProtocol.ts b/packages/vscode-ext-webview/src/shared/wireProtocol.ts new file mode 100644 index 000000000..0a615dbd4 --- /dev/null +++ b/packages/vscode-ext-webview/src/shared/wireProtocol.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Wire-protocol message types shared by both sides of the webview transport. + * + * These shapes describe what travels over `postMessage` between the extension + * host and the webview. They are side-agnostic (no `vscode`, no React) so they + * can live in the shared `.` entry and be imported by both the host dispatcher + * (`attachTrpc` / `WebviewController`) and the webview link (`vscodeLink`). + * + * The only external type they reference is tRPC's `Operation`, imported as a + * type and erased at compile time. + */ + +import { type Operation } from '@trpc/client'; + +/** + * Variant of a tRPC {@link Operation} used for the two control messages that + * tRPC v11 does not yet model natively: + * + * - `subscription.stop` ends a running subscription; + * - `abort` cancels an in-flight query or mutation. + * + * TODO: when tRPC v12 is released, `subscription.stop` should be supported + * natively; revisit then. + */ +export type StopOperation<TInput = unknown> = Omit<Operation<TInput>, 'type'> & { + type: 'subscription.stop' | 'abort'; +}; + +/** + * Messages sent from the webview/client to the extension/server. + * @id - A unique identifier for the message. + */ +export interface VsCodeLinkRequestMessage { + id: string; + // TODO, when tRPC v12 is released, 'subscription.stop' should be supported natively, until then, we're adding it manually. + // 'abort' is used to cancel in-flight queries and mutations. + op: Operation<unknown> | StopOperation<unknown>; +} + +/** + * Messages sent back from the extension/server to the webview/client. + * Each message sent back is a **response** to a previous VsCodeLinkRequestMessage. + * + * @id - The unique identifier of the message from the original request. + */ +export interface VsCodeLinkResponseMessage { + id: string; + result?: unknown; + error?: { + name: string; + message: string; + + code?: number; + stack?: string; + cause?: unknown; + data?: unknown; + }; + complete?: boolean; +} + +/** + * Structural guard for inbound transport responses (R766-C03). + * + * The webview `window` bus may also carry non-tRPC messages (VS Code internals, + * other libraries, or a legacy `postMessage` protocol). Only payloads shaped like + * a {@link VsCodeLinkResponseMessage} — a non-null object with its **own** string + * `id` — should be forwarded into the tRPC response path. Requiring an own string + * `id` (rather than a bare `'id' in data`) rejects a `null` / primitive payload, + * an inherited `id`, and a non-string `id`, mirroring the host-side + * `isTransportRequestMessage` guard. + */ +export function isTransportResponseMessage(data: unknown): data is VsCodeLinkResponseMessage { + return ( + data !== null && + typeof data === 'object' && + Object.hasOwn(data, 'id') && + typeof (data as { id: unknown }).id === 'string' + ); +} diff --git a/packages/vscode-ext-webview/src/testing/README.md b/packages/vscode-ext-webview/src/testing/README.md new file mode 100644 index 000000000..758ebe7ca --- /dev/null +++ b/packages/vscode-ext-webview/src/testing/README.md @@ -0,0 +1,9 @@ +# testing + +Shared test helpers. Ships no runtime code and is not part of any public entry +point. + +Contents: + +- `vscodeStub.ts`: a minimal stand-in for the `vscode` module so host-side units + can run under the test environment without the real VS Code API. diff --git a/packages/vscode-ext-webview/src/testing/vscodeStub.ts b/packages/vscode-ext-webview/src/testing/vscodeStub.ts new file mode 100644 index 000000000..fcc945411 --- /dev/null +++ b/packages/vscode-ext-webview/src/testing/vscodeStub.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal runtime stub of the `vscode` module for the package's Jest tests. + * + * The package's production code imports `vscode` only in the host facade + * ({@link WebviewController} / {@link openWebview}); every other module keeps + * `vscode` as a type-only import. Tests resolve `vscode` to this file via the + * `moduleNameMapper` entry in `jest.config.js`. Type-checking still uses the + * real `@types/vscode`; this only supplies the runtime values the facade reads. + */ + +type Listener<T> = (e: T) => void; + +interface MockDisposable { + dispose(): void; +} + +/** Stub of `vscode.EventEmitter`. */ +export class EventEmitter<T> { + private readonly listeners = new Set<Listener<T>>(); + + public readonly event = (listener: Listener<T>): MockDisposable => { + this.listeners.add(listener); + return { + dispose: (): void => { + this.listeners.delete(listener); + }, + }; + }; + + public fire(data: T): void { + for (const listener of [...this.listeners]) { + listener(data); + } + } + + public dispose(): void { + this.listeners.clear(); + } +} + +/** Stub of `vscode.Webview`. */ +class MockWebview { + public html = ''; + public readonly cspSource = 'vscode-webview://mock'; + /** Everything posted back to the webview, for test assertions. */ + public readonly posted: unknown[] = []; + private readonly messages = new EventEmitter<unknown>(); + + public readonly onDidReceiveMessage = (listener: Listener<unknown>): MockDisposable => + this.messages.event(listener); + + public asWebviewUri(uri: { toString(): string }): { toString(skipEncoding?: boolean): string } { + const value = `mock-webview:${uri.toString()}`; + return { toString: (): string => value }; + } + + public postMessage(message: unknown): Promise<boolean> { + this.posted.push(message); + return Promise.resolve(true); + } + + /** Test helper: simulate an inbound message from the webview. */ + public receive(message: unknown): void { + this.messages.fire(message); + } +} + +/** Stub of `vscode.WebviewPanel`. */ +export class MockWebviewPanel { + public readonly webview = new MockWebview(); + public iconPath: unknown; + public revealCount = 0; + /** `true` once `dispose()` has been called, for test assertions. */ + public disposed = false; + private readonly didDispose = new EventEmitter<void>(); + + public readonly onDidDispose = (listener: Listener<void>): MockDisposable => this.didDispose.event(listener); + + public reveal(_viewColumn?: number, _preserveFocus?: boolean): void { + this.revealCount += 1; + } + + public dispose(): void { + // Mirror the real panel: disposing is idempotent and fires `onDidDispose` + // exactly once. + if (this.disposed) { + return; + } + this.disposed = true; + this.didDispose.fire(); + } +} + +/** The most recently created panel, so tests can introspect it. */ +export let lastCreatedPanel: MockWebviewPanel | undefined; + +/** Stub of the `vscode.window` namespace. */ +export const window = { + createWebviewPanel(_viewType: string, _title: string, _viewColumn: unknown, _options: unknown): MockWebviewPanel { + lastCreatedPanel = new MockWebviewPanel(); + return lastCreatedPanel; + }, +}; + +/** Stub of the `vscode.Uri` namespace. */ +export const Uri = { + file(fsPath: string): { fsPath: string; toString(): string } { + return { fsPath, toString: (): string => fsPath }; + }, +}; + +/** Stub of `vscode.ViewColumn`. */ +export const ViewColumn = { + Active: -1, + Beside: -2, + One: 1, + Two: 2, + Three: 3, +} as const; + +/** Stub of `vscode.ExtensionMode`. */ +export const ExtensionMode = { + Production: 1, + Development: 2, + Test: 3, +} as const; + +/** Stub of the `vscode.l10n` namespace. */ +export const l10n: { bundle: Record<string, unknown> | undefined } = { + bundle: undefined, +}; diff --git a/packages/vscode-ext-webview/src/webview.ts b/packages/vscode-ext-webview/src/webview.ts new file mode 100644 index 000000000..d698265ed --- /dev/null +++ b/packages/vscode-ext-webview/src/webview.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Entry point for the framework-agnostic webview surface of + * `@microsoft/vscode-ext-webview` (the `./webview` subpath). + * + * Browser-side transport with no React dependency. A consumer using a UI + * framework other than React imports from here; the React hooks in `./react` + * are built on top of this entry. + */ + +export * from './webview/index'; diff --git a/packages/vscode-ext-webview/src/webview/README.md b/packages/vscode-ext-webview/src/webview/README.md new file mode 100644 index 000000000..97174c970 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/README.md @@ -0,0 +1,20 @@ +# webview + +Webview transport, framework-agnostic. Runs in the panel's browser context and +imports neither `vscode` nor `react`, so any UI framework (or none) can use it. + +Import path: + +```ts +import { ... } from '@microsoft/vscode-ext-webview/webview'; +``` + +Contents: + +- `connectTrpc.ts`: creates the tRPC client and the event channel for a webview. +- `events.ts`: `createEventChannel` and `RpcEventChannel`, to observe + query and mutation success, error, and abort. +- `vscodeLink.ts`, `errorLink.ts`: the tRPC links the above build on. + +Rule for contributors and agents: do not import `react` here. React bindings +live in `react/`. diff --git a/packages/vscode-ext-webview/src/webview/connectTrpc.test.ts b/packages/vscode-ext-webview/src/webview/connectTrpc.test.ts new file mode 100644 index 000000000..79a54e5d8 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/connectTrpc.test.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { initWebviewTrpc } from '../shared/initWebviewTrpc'; +import { type VsCodeLinkResponseMessage } from '../shared/wireProtocol'; +import { connectTrpc, type VsCodeApiLike } from './connectTrpc'; + +// A router whose *type* parametrizes the client; the resolver bodies are never +// executed (the client side only proxies calls over the transport). +const { router, publicProcedure } = initWebviewTrpc(); +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` to type the client +const appRouter = router({ + greet: publicProcedure.query(() => 'unused'), +}); +type AppRouter = typeof appRouter; + +// The package jest env is `node`, so there is no DOM. We install a minimal +// `window` that records 'message' listeners and lets a test deliver responses. +type MessageListener = (event: MessageEvent) => void; +const messageListeners = new Set<MessageListener>(); + +function deliver(message: unknown): void { + for (const listener of [...messageListeners]) { + listener({ data: message } as MessageEvent); + } +} + +beforeEach(() => { + messageListeners.clear(); + Object.assign(globalThis, { + window: { + addEventListener: (type: string, listener: MessageListener) => { + if (type === 'message') { + messageListeners.add(listener); + } + }, + removeEventListener: (_type: string, listener: MessageListener) => { + messageListeners.delete(listener); + }, + }, + }); +}); + +afterEach(() => { + messageListeners.clear(); + Reflect.deleteProperty(globalThis, 'window'); +}); + +/** + * A fake VS Code webview API that echoes a response for each request using the + * request's own id, so the client's per-operation handler always matches. + */ +function echoingApi(respond: (requestId: string) => VsCodeLinkResponseMessage): { + api: VsCodeApiLike; + sent: { id: string }[]; +} { + const sent: { id: string }[] = []; + const api: VsCodeApiLike = { + postMessage(message: unknown) { + const request = message as { id: string }; + sent.push(request); + // Deliver the response on a later microtask, after the client's + // onReceive listener is registered and the promise is awaited. + queueMicrotask(() => deliver(respond(request.id))); + }, + }; + return { api, sent }; +} + +describe('connectTrpc', () => { + it('returns a client and an observe-only events channel', () => { + const { api } = echoingApi((id) => ({ id, result: null })); + const { client, events } = connectTrpc<AppRouter>(api); + + expect(typeof events.onSuccess).toBe('function'); + expect(typeof events.onError).toBe('function'); + expect(typeof events.onAborted).toBe('function'); + expect(client.greet).toBeDefined(); + }); + + it('drives a query through the transport and surfaces a success event', async () => { + const { api, sent } = echoingApi((id) => ({ id, result: 'pong' })); + const onSuccess = jest.fn(); + + const { client, events } = connectTrpc<AppRouter>(api); + events.onSuccess(onSuccess); + + const result = await client.greet.query(); + + expect(result).toBe('pong'); + expect(sent).toHaveLength(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ type: 'query', path: 'greet' }), 'pong'); + }); + + it('surfaces an error event and forwards it to the onError option', async () => { + const { api } = echoingApi((id) => ({ id, error: { name: 'Error', message: 'boom' } })); + const onErrorOption = jest.fn(); + const onErrorChannel = jest.fn(); + + const { client, events } = connectTrpc<AppRouter>(api, { onError: onErrorOption }); + events.onError(onErrorChannel); + + await expect(client.greet.query()).rejects.toThrow(); + + expect(onErrorChannel).toHaveBeenCalledTimes(1); + expect(onErrorOption).toHaveBeenCalledTimes(1); + expect(onErrorChannel).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ type: 'query', path: 'greet' }), + ); + }); + + it('reports an aborted call via onAborted, not onError', async () => { + const { api } = echoingApi((id) => ({ id, result: 'never' })); + const onError = jest.fn(); + const onAborted = jest.fn(); + + const { client, events } = connectTrpc<AppRouter>(api); + events.onError(onError); + events.onAborted(onAborted); + + await expect(client.greet.query(undefined, { signal: AbortSignal.abort() })).rejects.toThrow(); + + expect(onAborted).toHaveBeenCalledTimes(1); + expect(onAborted).toHaveBeenCalledWith(expect.objectContaining({ type: 'query', path: 'greet' })); + expect(onError).not.toHaveBeenCalled(); + }); + + it('ignores foreign / null window messages and still resolves queries [R766-N01]', async () => { + const sent: { id: string }[] = []; + const api: VsCodeApiLike = { + postMessage(message: unknown) { + sent.push(message as { id: string }); + }, + }; + const { client } = connectTrpc<AppRouter>(api); + + // Start a query so the per-operation response listener is registered on + // `window`, then let all microtasks settle so the link has subscribed. + const pending = client.greet.query(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sent).toHaveLength(1); + + // Foreign traffic on the shared window bus must be ignored without throwing + // (reading `.id` off `null` threw before R766-N01) and must not resolve the + // pending query. + expect(() => deliver(null)).not.toThrow(); + expect(() => deliver('a string')).not.toThrow(); + expect(() => deliver({ notAResponse: true })).not.toThrow(); + // R766-C03: a non-string `id` and an inherited (non-own) `id` are also + // ignored — the guard requires an own, string `id`. The inherited case is + // the sharp one: its `id` equals the pending request id, so a bare + // `'id' in data` guard would forward it and resolve the query with + // `undefined` before the real response arrives. + expect(() => deliver({ id: 123 })).not.toThrow(); + expect(() => deliver(Object.create({ id: sent[0].id }))).not.toThrow(); + + // The matching response still flows through and resolves the query. + deliver({ id: sent[0].id, result: 'pong' }); + await expect(pending).resolves.toBe('pong'); + }); +}); diff --git a/packages/vscode-ext-webview/src/webview/connectTrpc.ts b/packages/vscode-ext-webview/src/webview/connectTrpc.ts new file mode 100644 index 000000000..d50b735b2 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/connectTrpc.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Framework-agnostic webview client factory. + * + * `connectTrpc` bundles the pieces a webview needs to talk to the extension + * host into a single call: it creates an {@link RpcEventChannel}, wires the + * default `postMessage` / `window` `message` transport, and assembles a tRPC + * client whose links publish every query/mutation outcome into the channel. + * + * It has no React dependency, so non-React (or legacy `postMessage`) webviews + * can use it directly. The React hooks in `./react` are thin wrappers over it. + */ + +import { createTRPCClient, type CreateTRPCClient, loggerLink, type TRPCLink } from '@trpc/client'; +import { type AnyRouter } from '@trpc/server'; +import { + isTransportResponseMessage, + type VsCodeLinkRequestMessage, + type VsCodeLinkResponseMessage, +} from '../shared/wireProtocol'; +import { type ErrorHandler, eventLink } from './errorLink'; +import { createEventChannel, type ObserverErrorHandler, type RpcEventChannel } from './events'; +import { vscodeLink } from './vscodeLink'; + +/** + * The slice of the VS Code webview API that {@link connectTrpc} needs: a way to + * post a message to the extension host. The full `WebviewApi` from + * `acquireVsCodeApi()` satisfies this structurally. + */ +export interface VsCodeApiLike { + postMessage(message: unknown): void; +} + +/** Options accepted by {@link connectTrpc}. */ +export interface ConnectTrpcOptions { + /** + * Convenience error observer for queries and mutations, subscribed to the + * returned channel's {@link RpcEventChannel.onError | onError}. Equivalent + * to calling `events.onError((err) => onError(err))` yourself. Aborted + * calls are reported via `onAborted`, not here. + */ + onError?: ErrorHandler; + + /** + * When `true`, prepends tRPC's `loggerLink()` so every query / mutation / + * subscription is logged to the **webview devtools console**. Off by default + * so production webviews stay quiet unless observability is explicitly + * requested. See the package's Observability docs for how to open the webview + * console from VS Code. + */ + logger?: boolean; + + /** + * Called when one of your event-channel observers (`events.onSuccess` / + * `onError` / `onAborted`) throws. The channel always isolates the throw so a + * broken observer cannot break the tRPC call it was only observing; this hook + * only decides where the isolated error goes. Defaults to `console.error`. + * Provide your own to route observer failures to telemetry. + */ + onObserverError?: ObserverErrorHandler; +} + +/** The pair returned by {@link connectTrpc}. */ +export interface ConnectTrpcResult<TRouter extends AnyRouter> { + /** The fully-typed tRPC client. */ + readonly client: CreateTRPCClient<TRouter>; + /** Observe-only event channel surfacing every query/mutation outcome. */ + readonly events: RpcEventChannel; +} + +/** + * Connect a webview to the extension host. + * + * @template TRouter - The application's root tRPC router type. + * @param vscodeApi - The object returned by `acquireVsCodeApi()` (or anything + * with a compatible `postMessage`). + * @param options - Optional configuration (see {@link ConnectTrpcOptions}). + * @returns The tRPC `client` and the observe-only `events` channel. + * + * @example + * ```ts + * import { connectTrpc } from '@microsoft/vscode-ext-webview/webview'; + * import type { AppRouter } from '../_integration/appRouter'; + * + * const { client, events } = connectTrpc<AppRouter>(acquireVsCodeApi()); + * events.onAborted((info) => console.debug('canceled', info.path)); + * const rows = await client.documents.find.query({ limit: 10 }); + * ``` + */ +export function connectTrpc<TRouter extends AnyRouter>( + vscodeApi: VsCodeApiLike, + options?: ConnectTrpcOptions, +): ConnectTrpcResult<TRouter> { + const channel = createEventChannel({ onObserverError: options?.onObserverError }); + + if (options?.onError) { + const onError = options.onError; + channel.onError((error) => onError(error)); + } + + // Send a request message to the extension host. + const send = (message: VsCodeLinkRequestMessage): void => { + vscodeApi.postMessage(message); + }; + + // Register a handler for response messages from the extension host. tRPC + // calls this when a request is made and the returned unsubscribe when the + // response has been consumed, so the listener is per-operation. + const onReceive = (callback: (message: VsCodeLinkResponseMessage) => void): (() => void) => { + const handler = (event: MessageEvent): void => { + // Structural guard (R766-N01 / R766-C03): the webview `window` bus may + // also carry non-tRPC messages (VS Code internals, other libraries, or + // a legacy `postMessage` protocol). Only forward payloads shaped like a + // response — a non-null object with its own string `id` — so a `null`, + // primitive, inherited-`id`, or non-string-`id` message is ignored + // (reading `.id` off a `null` / primitive would also throw). + if (isTransportResponseMessage(event.data)) { + callback(event.data); + } + }; + + window.addEventListener('message', handler); + return () => { + window.removeEventListener('message', handler); + }; + }; + + // Logging is opt-in: only prepend tRPC's loggerLink when the consumer asks + // for it, so a production webview does not log every RPC to the console. + const links: TRPCLink<TRouter>[] = []; + if (options?.logger) { + links.push(loggerLink<TRouter>()); + } + links.push(eventLink<TRouter>(channel), vscodeLink<TRouter>({ send, onReceive })); + + const client = createTRPCClient<TRouter>({ links }); + + return { client, events: channel }; +} diff --git a/packages/vscode-ext-react-webview/src/webview-client/errorLink.test.ts b/packages/vscode-ext-webview/src/webview/errorLink.test.ts similarity index 100% rename from packages/vscode-ext-react-webview/src/webview-client/errorLink.test.ts rename to packages/vscode-ext-webview/src/webview/errorLink.test.ts diff --git a/packages/vscode-ext-webview/src/webview/errorLink.ts b/packages/vscode-ext-webview/src/webview/errorLink.ts new file mode 100644 index 000000000..5dc2a3013 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/errorLink.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * tRPC links that observe the outcome of queries and mutations and publish them + * into an {@link RpcEventChannel}. + * + * Why this exists. The default tRPC error flow propagates the error to the + * caller of `.query()` / `.mutation()`, where each call site is responsible for + * handling it. That works, but it forces every call site to remember to add a + * `.catch(...)`. When a consumer has a single place where webview-side outcomes + * should surface (an ARIA announcer, a FluentUI `Toaster`, telemetry, etc.), + * an event channel is where that handler plugs in once. + * + * {@link errorLink} is the thin, error-only convenience: it owns a private + * channel and bridges that channel's `onError` to the supplied callback. The + * underlying {@link eventLink} is the general publisher that feeds a channel + * with success / error / aborted outcomes; `connectTrpc` uses it directly so + * consumers can observe every outcome. + * + * Important semantics: + * - Observation is **in addition to** the normal tRPC flow. The value/error is + * re-emitted on the observable so call-site handlers still fire. + * - **Aborts are separated from errors.** When the operation's `AbortSignal` + * is already aborted, the outcome is published via `emitAborted`, not + * `emitError`, so a user cancel does not surface as an error. + * - Subscription outcomes are **not** published to the channel. Subscriptions + * have their own per-call callbacks on `.subscribe(...)`; mixing the two + * would surface subscription events twice. + */ + +import { type TRPCClientError, type TRPCLink } from '@trpc/client'; +import { type AnyRouter } from '@trpc/server'; +// eslint-disable-next-line import/no-internal-modules -- tRPC's own link examples import from /server/observable: https://trpc.io/docs/client/links#example +import { observable } from '@trpc/server/observable'; +import { type CallInfo, createEventChannel, type RpcEventEmitter } from './events'; + +/** + * Callback invoked by {@link errorLink} for each query/mutation that errors + * out. The error is the same value the link is about to re-emit to the caller, + * normalized to an `Error` instance. + */ +export type ErrorHandler = (error: Error) => void; + +/** + * Normalize an unknown rejection into an `Error` instance. + */ +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +/** + * General-purpose publishing link: for each query/mutation it publishes the + * outcome into `emitter` (`emitSuccess` / `emitError` / `emitAborted`) and + * re-emits the value/error/complete down the link chain unchanged. Subscription + * outcomes are passed through without publishing. + * + * Not part of the public `./webview` surface; `errorLink` and `connectTrpc` + * build on it. + */ +export function eventLink<TRouter extends AnyRouter>(emitter: RpcEventEmitter): TRPCLink<TRouter> { + return () => { + return ({ next, op }) => { + const info: CallInfo = { type: op.type, path: op.path, id: op.id }; + + return observable((observer) => { + return next(op).subscribe({ + next(value) { + if (op.type !== 'subscription') { + const data = (value as { result?: { data?: unknown } })?.result?.data; + emitter.emitSuccess(info, data); + } + observer.next(value); + }, + error(err: unknown) { + // Subscriptions report their own errors via the per-call + // `.subscribe({ onError })` callback; only publish + // query/mutation outcomes so they are not surfaced twice. + if (op.type !== 'subscription') { + if (op.signal?.aborted) { + emitter.emitAborted(info); + } else { + emitter.emitError(toError(err), info); + } + } + observer.error(err as TRPCClientError<TRouter>); + }, + complete() { + observer.complete(); + }, + }); + }); + }; + }; +} + +/** + * Error-only convenience link. Use this in the `links` array passed to + * `createTRPCClient`, before {@link vscodeLink}: + * + * @example + * ```ts + * import { createTRPCClient } from '@trpc/client'; + * import { errorLink, vscodeLink } from '@microsoft/vscode-ext-webview/webview'; + * + * const trpcClient = createTRPCClient<AppRouter>({ + * links: [ + * errorLink<AppRouter>((err) => announcer.announceError(err.message)), + * vscodeLink<AppRouter>({ send, onReceive }), + * ], + * }); + * ``` + * + * It is a thin shim over {@link eventLink}: it owns a private channel and + * forwards that channel's error events to `onError`. For success/aborted + * observation (or one channel shared by several observers), use `connectTrpc`, + * which exposes the full {@link RpcEventChannel}. + */ +export function errorLink<TRouter extends AnyRouter>(onError: ErrorHandler): TRPCLink<TRouter> { + const channel = createEventChannel(); + channel.onError((error) => onError(error)); + return eventLink<TRouter>(channel); +} diff --git a/packages/vscode-ext-webview/src/webview/events.test.ts b/packages/vscode-ext-webview/src/webview/events.test.ts new file mode 100644 index 000000000..e7c74d67b --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/events.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CallInfo, createEventChannel } from './events'; + +const queryInfo: CallInfo = { type: 'query', path: 'demo.find', id: 1 }; + +describe('createEventChannel', () => { + it('routes each outcome only to its own handler kind (abort vs error vs success)', () => { + const channel = createEventChannel(); + const onSuccess = jest.fn(); + const onError = jest.fn(); + const onAborted = jest.fn(); + + channel.onSuccess(onSuccess); + channel.onError(onError); + channel.onAborted(onAborted); + + const error = new Error('boom'); + channel.emitSuccess(queryInfo, { value: 42 }); + channel.emitError(error, queryInfo); + channel.emitAborted(queryInfo); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(queryInfo, { value: 42 }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error, queryInfo); + + expect(onAborted).toHaveBeenCalledTimes(1); + expect(onAborted).toHaveBeenCalledWith(queryInfo); + }); + + it('an aborted outcome never reaches the error handler and vice versa', () => { + const channel = createEventChannel(); + const onError = jest.fn(); + const onAborted = jest.fn(); + + channel.onError(onError); + channel.onAborted(onAborted); + + channel.emitAborted(queryInfo); + expect(onError).not.toHaveBeenCalled(); + expect(onAborted).toHaveBeenCalledTimes(1); + + channel.emitError(new Error('x'), queryInfo); + expect(onAborted).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('stops invoking a handler after it unsubscribes', () => { + const channel = createEventChannel(); + const onError = jest.fn(); + const unsubscribe = channel.onError(onError); + + channel.emitError(new Error('first'), queryInfo); + unsubscribe(); + channel.emitError(new Error('second'), queryInfo); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'first' }), queryInfo); + }); + + it('is idempotent when the same unsubscribe runs twice', () => { + const channel = createEventChannel(); + const a = jest.fn(); + const b = jest.fn(); + const unsubscribeA = channel.onSuccess(a); + channel.onSuccess(b); + + unsubscribeA(); + unsubscribeA(); + + channel.emitSuccess(queryInfo, null); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('snapshot-safe: a handler that removes another handler mid-dispatch does not skip the snapshot', () => { + const channel = createEventChannel(); + const calls: string[] = []; + + // `first` unsubscribes `second` while the error is being dispatched. + const second = jest.fn(() => calls.push('second')); + const unsubscribeSecond = channel.onError(second); + channel.onError(() => { + calls.push('first'); + unsubscribeSecond(); + }); + + channel.emitError(new Error('one'), queryInfo); + // Both were in the snapshot taken before dispatch, so both ran. + expect(calls).toEqual(['second', 'first']); + + // On the next dispatch, `second` is gone. + calls.length = 0; + channel.emitError(new Error('two'), queryInfo); + expect(calls).toEqual(['first']); + }); + + it('snapshot-safe: a handler subscribed mid-dispatch is not called for the in-flight event', () => { + const channel = createEventChannel(); + const late = jest.fn(); + + channel.onAborted(() => { + channel.onAborted(late); + }); + + channel.emitAborted(queryInfo); + // `late` was added during dispatch, so it must not see the in-flight event. + expect(late).not.toHaveBeenCalled(); + + channel.emitAborted(queryInfo); + expect(late).toHaveBeenCalledTimes(1); + }); + + describe('observer isolation (R766-N05)', () => { + it('isolates a throwing handler: later handlers still run and emit does not throw', () => { + const channel = createEventChannel({ onObserverError: jest.fn() }); + const order: string[] = []; + channel.onSuccess(() => { + order.push('first'); + throw new Error('observer boom'); + }); + channel.onSuccess(() => { + order.push('second'); + }); + + expect(() => channel.emitSuccess(queryInfo, null)).not.toThrow(); + // The throw from 'first' did not skip 'second' nor escape emit. + expect(order).toEqual(['first', 'second']); + }); + + it('routes a thrown observer error to onObserverError with the call info and phase', () => { + const onObserverError = jest.fn(); + const channel = createEventChannel({ onObserverError }); + const boom = new Error('observer boom'); + channel.onError(() => { + throw boom; + }); + + channel.emitError(new Error('call failed'), queryInfo); + + expect(onObserverError).toHaveBeenCalledTimes(1); + expect(onObserverError).toHaveBeenCalledWith(boom, { info: queryInfo, phase: 'error' }); + }); + + it('defaults the sink to console.error when none is provided', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + const channel = createEventChannel(); + channel.onAborted(() => { + throw new Error('observer boom'); + }); + expect(() => channel.emitAborted(queryInfo)).not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + + it('never lets a throw from the sink itself escape dispatch', () => { + const channel = createEventChannel({ + onObserverError: () => { + throw new Error('sink boom'); + }, + }); + channel.onSuccess(() => { + throw new Error('observer boom'); + }); + + expect(() => channel.emitSuccess(queryInfo, null)).not.toThrow(); + }); + }); +}); diff --git a/packages/vscode-ext-webview/src/webview/events.ts b/packages/vscode-ext-webview/src/webview/events.ts new file mode 100644 index 000000000..c55cd5c66 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/events.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Client-side event channel for observing the outcome of webview tRPC calls. + * + * Why this exists. A webview usually has a few cross-cutting observers that + * want to react to *every* query/mutation outcome in one place: a FluentUI + * `Toaster`, an ARIA announcer, a status-bar widget, telemetry. Wiring each of + * those into every call site (or into a single `onError` callback) is + * repetitive and conflates two genuinely different outcomes: a real error and a + * user-initiated cancel. The channel separates them: + * + * - {@link RpcEventChannel.onSuccess | onSuccess} - the call resolved; + * - {@link RpcEventChannel.onError | onError} - the call rejected with an error; + * - {@link RpcEventChannel.onAborted | onAborted} - the call was canceled. + * + * Separating *aborted* from *errored* means a user cancel does not surface as an + * error toast. + * + * The channel is intentionally **observer-only**: handlers cannot mutate the + * value or turn an error into a success. For that, write a `TRPCLink`. + * + * `createEventChannel()` returns an object that is both the observe side + * ({@link RpcEventChannel}) and the publish side ({@link RpcEventEmitter}). The + * transport links publish into the emit side; consumers receive the channel + * narrowed to {@link RpcEventChannel}. + */ + +/** Unsubscribes a handler that was registered on an {@link RpcEventChannel}. */ +export type Unsubscribe = () => void; + +/** Identifies the call an event refers to. */ +export interface CallInfo { + /** The tRPC operation type. */ + readonly type: 'query' | 'mutation' | 'subscription'; + /** The dotted procedure path, e.g. `documents.find`. */ + readonly path: string; + /** The tRPC operation id, when available. */ + readonly id?: number | string; +} + +/** Handler invoked when a call resolves successfully. */ +export type SuccessHandler = (info: CallInfo, data: unknown) => void; + +/** Handler invoked when a call rejects with an error. */ +export type ErrorEventHandler = (error: Error, info: CallInfo) => void; + +/** Handler invoked when a call is aborted (canceled). */ +export type AbortedHandler = (info: CallInfo) => void; + +/** The emit phase whose observer threw. */ +export type ObserverErrorPhase = 'success' | 'error' | 'aborted'; + +/** Context handed to an {@link ObserverErrorHandler} alongside the thrown value. */ +export interface ObserverErrorContext { + /** The call whose outcome was being observed when the handler threw. */ + readonly info: CallInfo; + /** The emit phase whose handler threw. */ + readonly phase: ObserverErrorPhase; +} + +/** + * Invoked when one of the channel's own `on*` handlers throws. The channel + * always isolates the throw so an observer cannot break the tRPC call it is only + * observing; this hook decides where the isolated error goes (e.g. telemetry). + */ +export type ObserverErrorHandler = (error: unknown, context: ObserverErrorContext) => void; + +/** + * The observe side of an event channel. Each `on*` method registers a handler + * and returns an {@link Unsubscribe} to remove it. Registering or removing a + * handler while an event is being dispatched is safe and does not affect the + * in-flight dispatch (handlers are snapshotted before they are called). + */ +export interface RpcEventChannel { + onSuccess(handler: SuccessHandler): Unsubscribe; + onError(handler: ErrorEventHandler): Unsubscribe; + onAborted(handler: AbortedHandler): Unsubscribe; +} + +/** + * The publish side of an event channel. The transport links call these to + * report outcomes; consumers do not see this surface. + */ +export interface RpcEventEmitter { + emitSuccess(info: CallInfo, data: unknown): void; + emitError(error: Error, info: CallInfo): void; + emitAborted(info: CallInfo): void; +} + +/** An event channel that exposes both the observe and publish surfaces. */ +export interface EventChannel extends RpcEventChannel, RpcEventEmitter {} + +/** + * Registers `handler` in `handlers` and returns an idempotent unsubscribe. + */ +function subscribe<THandler>(handlers: Set<THandler>, handler: THandler): Unsubscribe { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; +} + +/** Options for {@link createEventChannel}. */ +export interface EventChannelOptions { + /** + * Called when one of the channel's own `on*` handlers throws. The channel + * **always** isolates the throw so an observer cannot break the tRPC call it + * is only observing (upholding the observer-only contract above); this hook + * only decides where the isolated error goes. Defaults to `console.error`. + * Provide your own to route observer failures to telemetry. + */ + onObserverError?: ObserverErrorHandler; +} + +/** + * Create a fresh {@link EventChannel}. + * + * Dispatch is snapshot-safe: each `emit*` iterates over a copy of the handler + * set, so a handler that subscribes or unsubscribes another handler during + * dispatch never corrupts the in-flight iteration. + * + * Dispatch is also throw-safe: a handler that throws is isolated and routed to + * `options.onObserverError` (default `console.error`) rather than propagating + * into the tRPC link chain and breaking the call it was only observing. + */ +export function createEventChannel(options?: EventChannelOptions): EventChannel { + const successHandlers = new Set<SuccessHandler>(); + const errorHandlers = new Set<ErrorEventHandler>(); + const abortedHandlers = new Set<AbortedHandler>(); + + const onObserverError: ObserverErrorHandler = + options?.onObserverError ?? + ((error, context) => + // eslint-disable-next-line no-console -- default zero-config observer-error sink + console.error( + `[vscode-ext-webview] an event observer threw during '${context.phase}' of '${context.info.path}'`, + error, + )); + + /** + * Runs one observer in isolation: a throw is routed to `onObserverError` + * instead of propagating into the tRPC link chain. A throw from the sink + * itself is swallowed - it must never affect dispatch. + */ + const runObserver = (phase: ObserverErrorPhase, info: CallInfo, call: () => void): void => { + try { + call(); + } catch (error) { + try { + onObserverError(error, { info, phase }); + } catch { + // The sink itself threw; there is nothing useful to do and it + // must not break dispatch. + } + } + }; + + return { + onSuccess(handler) { + return subscribe(successHandlers, handler); + }, + onError(handler) { + return subscribe(errorHandlers, handler); + }, + onAborted(handler) { + return subscribe(abortedHandlers, handler); + }, + emitSuccess(info, data) { + for (const handler of [...successHandlers]) { + runObserver('success', info, () => handler(info, data)); + } + }, + emitError(error, info) { + for (const handler of [...errorHandlers]) { + runObserver('error', info, () => handler(error, info)); + } + }, + emitAborted(info) { + for (const handler of [...abortedHandlers]) { + runObserver('aborted', info, () => handler(info)); + } + }, + }; +} diff --git a/packages/vscode-ext-webview/src/webview/index.ts b/packages/vscode-ext-webview/src/webview/index.ts new file mode 100644 index 000000000..d637b1887 --- /dev/null +++ b/packages/vscode-ext-webview/src/webview/index.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Framework-agnostic webview surface (the `./webview` subpath). + * + * This is the browser-side transport with no React dependency: the tRPC links + * (`vscodeLink`, `errorLink`) plus the wire-protocol message types. React hooks + * live in `./react` and build on top of this. Reshaped across Phase C to add + * `connectTrpc` and `createEventChannel`. + */ + +export { type VsCodeLinkRequestMessage, type VsCodeLinkResponseMessage } from '../shared/wireProtocol'; +export { connectTrpc, type ConnectTrpcOptions, type ConnectTrpcResult, type VsCodeApiLike } from './connectTrpc'; +export { errorLink, type ErrorHandler } from './errorLink'; +export { + createEventChannel, + type AbortedHandler, + type CallInfo, + type ErrorEventHandler, + type EventChannel, + type EventChannelOptions, + type ObserverErrorContext, + type ObserverErrorHandler, + type ObserverErrorPhase, + type RpcEventChannel, + type RpcEventEmitter, + type SuccessHandler, + type Unsubscribe, +} from './events'; +export { vscodeLink, type VSCodeLinkOptions } from './vscodeLink'; diff --git a/packages/vscode-ext-react-webview/src/webview-client/vscodeLink.test.ts b/packages/vscode-ext-webview/src/webview/vscodeLink.test.ts similarity index 100% rename from packages/vscode-ext-react-webview/src/webview-client/vscodeLink.test.ts rename to packages/vscode-ext-webview/src/webview/vscodeLink.test.ts diff --git a/packages/vscode-ext-react-webview/src/webview-client/vscodeLink.ts b/packages/vscode-ext-webview/src/webview/vscodeLink.ts similarity index 88% rename from packages/vscode-ext-react-webview/src/webview-client/vscodeLink.ts rename to packages/vscode-ext-webview/src/webview/vscodeLink.ts index fd89df443..a52087531 100644 --- a/packages/vscode-ext-react-webview/src/webview-client/vscodeLink.ts +++ b/packages/vscode-ext-webview/src/webview/vscodeLink.ts @@ -7,42 +7,11 @@ import { TRPCClientError, type Operation, type TRPCLink } from '@trpc/client'; import { type AnyRouter } from '@trpc/server'; // eslint-disable-next-line import/no-internal-modules -- Their example uses a reference from /server/ and so do we: https://trpc.io/docs/client/links#example import { observable } from '@trpc/server/observable'; +import { type VsCodeLinkRequestMessage, type VsCodeLinkResponseMessage } from '../shared/wireProtocol'; -type StopOperation<TInput = unknown> = Omit<Operation<TInput>, 'type'> & { - type: 'subscription.stop' | 'abort'; -}; - -/** - * Messages sent from the webview/client to the extension/server. - * @id - A unique identifier for the message/ - */ -export interface VsCodeLinkRequestMessage { - id: string; - // TODO, when tRPC v12 is released, 'subscription.stop' should be supported natively, until then, we're adding it manually. - // 'abort' is used to cancel in-flight queries and mutations. - op: Operation<unknown> | StopOperation<unknown>; -} - -/** - * Messages sent back from the extension/server to the webview/client. - * Each message sent back is a **response** to a previous message VsCodeLinkRequestMessage - * - * @id - The unique identifier of the message from the original request - */ -export interface VsCodeLinkResponseMessage { - id: string; - result?: unknown; - error?: { - name: string; - message: string; - - code?: number; - stack?: string; - cause?: unknown; - data?: unknown; - }; - complete?: boolean; -} +// Re-export the wire-protocol message types from their shared home so existing +// importers of `vscodeLink` keep resolving them here. +export { type VsCodeLinkRequestMessage, type VsCodeLinkResponseMessage } from '../shared/wireProtocol'; export interface VSCodeLinkOptions { // Function to send a message to the server / extension diff --git a/packages/vscode-ext-react-webview/tsconfig.json b/packages/vscode-ext-webview/tsconfig.json similarity index 86% rename from packages/vscode-ext-react-webview/tsconfig.json rename to packages/vscode-ext-webview/tsconfig.json index c5d572606..f1ee44d3b 100644 --- a/packages/vscode-ext-react-webview/tsconfig.json +++ b/packages/vscode-ext-webview/tsconfig.json @@ -21,5 +21,5 @@ "types": ["node", "vscode", "vscode-webview", "jest"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] + "exclude": ["node_modules", "dist", "src/testing/**", "src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/src/commands/openCollectionView/openCollectionView.ts b/src/commands/openCollectionView/openCollectionView.ts index 9c7d7bf87..6a469d96f 100644 --- a/src/commands/openCollectionView/openCollectionView.ts +++ b/src/commands/openCollectionView/openCollectionView.ts @@ -11,7 +11,7 @@ import { ClusterSession } from '../../documentdb/ClusterSession'; import { inferViewIdFromTreeId } from '../../documentdb/Views'; import { type CollectionItem } from '../../tree/documentdb/CollectionItem'; import { trackJourneyCorrelationId } from '../../utils/commandTelemetry'; -import { CollectionViewController } from '../../webviews/documentdb/collectionView/collectionViewController'; +import { openCollectionWebview } from '../../webviews/documentdb/collectionView/collectionViewController'; export async function openCollectionView(context: IActionContext, node: CollectionItem) { // added manually here as this function can by called bypassing our general command registration @@ -70,7 +70,7 @@ export async function openCollectionViewInternal( feedbackSignalsEnabled = false; } - const view = new CollectionViewController({ + const view = openCollectionWebview({ sessionId: sessionId, clusterId: props.clusterId, clusterDisplayName: props.clusterDisplayName, diff --git a/src/commands/openDocument/openDocument.ts b/src/commands/openDocument/openDocument.ts index e29fb6aca..7465801d6 100644 --- a/src/commands/openDocument/openDocument.ts +++ b/src/commands/openDocument/openDocument.ts @@ -6,7 +6,7 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { Views } from '../../documentdb/Views'; -import { DocumentsViewController } from '../../webviews/documentdb/documentView/documentsViewController'; +import { openDocumentWebview } from '../../webviews/documentdb/documentView/documentsViewController'; export function openDocumentView( _context: IActionContext, @@ -27,7 +27,7 @@ export function openDocumentView( mode: string; }, ): void { - const view = new DocumentsViewController({ + const view = openDocumentWebview({ id: props.id, clusterId: props.clusterId, diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 766a63720..29db8cdb8 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -36,7 +36,7 @@ import { } from 'mongodb'; import { Links } from '../constants'; import { ext } from '../extensionVariables'; -import { meterSilentCatch } from '../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../utils/accumulatingTelemetry'; import { type EmulatorConfiguration } from '../utils/emulatorConfiguration'; import { type AuthHandler } from './auth/AuthHandler'; import { AuthMethodId } from './auth/AuthMethod'; diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 1df2fc28c..85a5db0d5 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -105,7 +105,7 @@ import { type ClusterItemBase } from '../tree/documentdb/ClusterItemBase'; import { type CollectionItem } from '../tree/documentdb/CollectionItem'; import { type DatabaseItem } from '../tree/documentdb/DatabaseItem'; import { HelpAndFeedbackBranchDataProvider } from '../tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider'; -import { callWithAccumulatingTelemetry } from '../utils/callWithAccumulatingTelemetry'; +import { accumulateTelemetry } from '../utils/accumulatingTelemetry'; import { registerCommandWithModalErrors, registerCommandWithTreeNodeUnwrappingAndModalErrors, @@ -548,8 +548,8 @@ export class ClustersExtension implements vscode.Disposable { `Unknown completion source received: ${JSON.stringify(source)} (category: ${category ?? 'unknown'})`, ); } - void callWithAccumulatingTelemetry('completion.accepted', (accCtx) => { - accCtx.telemetry.measurements[`cat_${normalizedCategory}_src_${normalizedSource}`] = 1; + accumulateTelemetry('completion.accepted', (sample) => { + sample.measurements[`cat_${normalizedCategory}_src_${normalizedSource}`] = 1; }); }, ); diff --git a/src/documentdb/feedResultToSchemaStore.ts b/src/documentdb/feedResultToSchemaStore.ts index 53f52d3f1..45de90da6 100644 --- a/src/documentdb/feedResultToSchemaStore.ts +++ b/src/documentdb/feedResultToSchemaStore.ts @@ -14,7 +14,7 @@ */ import { type Document, type WithId } from 'mongodb'; -import { meterSilentCatch } from '../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../utils/accumulatingTelemetry'; import { SchemaStore } from './SchemaStore'; /** diff --git a/src/documentdb/mongoConnectionStrings.ts b/src/documentdb/mongoConnectionStrings.ts index 3b8305039..e619354b3 100644 --- a/src/documentdb/mongoConnectionStrings.ts +++ b/src/documentdb/mongoConnectionStrings.ts @@ -6,7 +6,7 @@ import { appendExtensionUserAgent, parseError, type IParsedError } from '@microsoft/vscode-azext-utils'; import { type MongoClient } from 'mongodb'; import { ParsedConnectionString } from '../ParsedConnectionString'; -import { meterSilentCatch } from '../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../utils/accumulatingTelemetry'; import { nonNullValue } from '../utils/nonNull'; import { connectToClient } from './connectToClient'; diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index 41952d541..8e5391a17 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -8,7 +8,7 @@ import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import type * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; -import { meterSilentCatch } from '../../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { getBatchSizeSetting } from '../../utils/workspacUtils'; import { CredentialCache } from '../CredentialCache'; import { type ExecutionResult, type PlaygroundConnection } from './types'; diff --git a/src/documentdb/query-language/playground-completions/CollectionNameCache.ts b/src/documentdb/query-language/playground-completions/CollectionNameCache.ts index a32a2d1cc..38418475c 100644 --- a/src/documentdb/query-language/playground-completions/CollectionNameCache.ts +++ b/src/documentdb/query-language/playground-completions/CollectionNameCache.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { ext } from '../../../extensionVariables'; -import { meterSilentCatch } from '../../../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../../../utils/accumulatingTelemetry'; import { ClustersClient } from '../../ClustersClient'; import { SchemaStore } from '../../SchemaStore'; import { PlaygroundService } from '../../playground/PlaygroundService'; diff --git a/src/documentdb/shell/DocumentDBShellPty.ts b/src/documentdb/shell/DocumentDBShellPty.ts index 5c286d121..f0fb6c408 100644 --- a/src/documentdb/shell/DocumentDBShellPty.ts +++ b/src/documentdb/shell/DocumentDBShellPty.ts @@ -8,7 +8,7 @@ import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { type CompletionCategory } from '../../telemetry/completionCategories'; -import { callWithAccumulatingTelemetry } from '../../utils/callWithAccumulatingTelemetry'; +import { accumulateTelemetry } from '../../utils/accumulatingTelemetry'; import { classifyCommand, extractRunCommandName } from '../../utils/classifyCommand'; import { ClustersClient } from '../ClustersClient'; import { CredentialCache } from '../CredentialCache'; @@ -1265,9 +1265,9 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { */ private trackCompletionAccepted(kind: CompletionCandidate['kind'], trigger: 'tab' | 'ghostText'): void { const category = DocumentDBShellPty.shellKindToCategory(kind); - void callWithAccumulatingTelemetry('completion.accepted', (ctx) => { - ctx.telemetry.measurements[`cat_${category}_src_shell`] = 1; - ctx.telemetry.measurements[`trigger_${trigger}`] = 1; + accumulateTelemetry('completion.accepted', (sample) => { + sample.measurements[`cat_${category}_src_shell`] = 1; + sample.measurements[`trigger_${trigger}`] = 1; }); } @@ -1275,8 +1275,8 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { * Track that a closing-brackets ghost text suggestion was shown. */ private trackClosingBracketsShown(): void { - void callWithAccumulatingTelemetry('shell.closingBrackets', (ctx) => { - ctx.telemetry.measurements.shown = 1; + accumulateTelemetry('shell.closingBrackets', (sample) => { + sample.measurements.shown = 1; }); } @@ -1284,8 +1284,8 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { * Track that a closing-brackets ghost text suggestion was accepted. */ private trackClosingBracketsAccepted(): void { - void callWithAccumulatingTelemetry('shell.closingBrackets', (ctx) => { - ctx.telemetry.measurements.accepted = 1; + accumulateTelemetry('shell.closingBrackets', (sample) => { + sample.measurements.accepted = 1; }); } @@ -1295,8 +1295,8 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { private trackCompletionListShown(candidates: readonly CompletionCandidate[]): void { const kind = candidates[0]?.kind ?? 'command'; const category = DocumentDBShellPty.shellKindToCategory(kind); - void callWithAccumulatingTelemetry('shell.completionList', (ctx) => { - ctx.telemetry.measurements[`shown_${category}`] = 1; + accumulateTelemetry('shell.completionList', (sample) => { + sample.measurements[`shown_${category}`] = 1; }); } @@ -1305,8 +1305,8 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { */ private trackCompletionGhostShown(kind: CompletionCandidate['kind']): void { const category = DocumentDBShellPty.shellKindToCategory(kind); - void callWithAccumulatingTelemetry('shell.completionGhost', (ctx) => { - ctx.telemetry.measurements[`shown_${category}`] = 1; + accumulateTelemetry('shell.completionGhost', (sample) => { + sample.measurements[`shown_${category}`] = 1; }); } @@ -1315,8 +1315,8 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { */ private trackCompletionGhostAccepted(kind: CompletionCandidate['kind']): void { const category = DocumentDBShellPty.shellKindToCategory(kind); - void callWithAccumulatingTelemetry('shell.completionGhost', (ctx) => { - ctx.telemetry.measurements[`accepted_${category}`] = 1; + accumulateTelemetry('shell.completionGhost', (sample) => { + sample.measurements[`accepted_${category}`] = 1; }); } } diff --git a/src/documentdb/shell/ShellOutputFormatter.ts b/src/documentdb/shell/ShellOutputFormatter.ts index 7785024d8..b53d3999b 100644 --- a/src/documentdb/shell/ShellOutputFormatter.ts +++ b/src/documentdb/shell/ShellOutputFormatter.ts @@ -6,7 +6,7 @@ import * as l10n from '@vscode/l10n'; import { EJSON } from 'bson'; import * as vscode from 'vscode'; -import { meterSilentCatch } from '../../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { type SerializableExecutionResult } from '../playground/workerTypes'; /** diff --git a/src/extension.ts b/src/extension.ts index fbbed3a55..498cfcab5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,7 +23,7 @@ import { PlaygroundDiagnostics } from './documentdb/playground/PlaygroundDiagnos import { PLAYGROUND_RESULT_SCHEME, PlaygroundResultProvider } from './documentdb/playground/PlaygroundResultProvider'; import { SchemaStore } from './documentdb/SchemaStore'; import { ext } from './extensionVariables'; -import { flushAccumulatingTelemetry } from './utils/callWithAccumulatingTelemetry'; +import { flushAccumulatedTelemetry } from './utils/accumulatingTelemetry'; import { globalUriHandler } from './vscodeUriHandler'; // Import the DocumentDB Extension API interfaces import { type AzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; @@ -148,8 +148,8 @@ export async function activateInternal( // this method is called when your extension is deactivated export function deactivateInternal(_context: vscode.ExtensionContext): void { // Flush any pending accumulated telemetry (high-frequency events batched via - // callWithAccumulatingTelemetry) so the last partial batch is not lost. - flushAccumulatingTelemetry(); + // accumulateTelemetry) so the last partial batch is not lost. + flushAccumulatedTelemetry(); } /** diff --git a/src/tree/documentdb/DatabaseItem.test.ts b/src/tree/documentdb/DatabaseItem.test.ts index 79fecb446..62b3a95d5 100644 --- a/src/tree/documentdb/DatabaseItem.test.ts +++ b/src/tree/documentdb/DatabaseItem.test.ts @@ -26,7 +26,7 @@ jest.mock('../../extensionVariables', () => ({ }, })); -jest.mock('../../utils/callWithAccumulatingTelemetry', () => ({ +jest.mock('../../utils/accumulatingTelemetry', () => ({ meterSilentCatch: jest.fn(), })); diff --git a/src/tree/documentdb/DatabaseItem.ts b/src/tree/documentdb/DatabaseItem.ts index 9b763161d..b59ba9b9a 100644 --- a/src/tree/documentdb/DatabaseItem.ts +++ b/src/tree/documentdb/DatabaseItem.ts @@ -10,7 +10,7 @@ import { COLLECTION_COUNT_LIMIT } from '../../constants'; import { ClustersClient, type DatabaseItemModel } from '../../documentdb/ClustersClient'; import { type Experience } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; -import { meterSilentCatch } from '../../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { getCountPrefix } from '../../utils/countPrefix'; import { escapeMarkdown } from '../../webviews/utils/escapeMarkdown'; import { type BaseClusterModel, type TreeCluster } from '../models/BaseClusterModel'; diff --git a/src/tree/documentdb/IndexesItem.test.ts b/src/tree/documentdb/IndexesItem.test.ts index 6b9df6637..e12ea4f24 100644 --- a/src/tree/documentdb/IndexesItem.test.ts +++ b/src/tree/documentdb/IndexesItem.test.ts @@ -26,7 +26,7 @@ jest.mock('../../extensionVariables', () => ({ }, })); -jest.mock('../../utils/callWithAccumulatingTelemetry', () => ({ +jest.mock('../../utils/accumulatingTelemetry', () => ({ meterSilentCatch: jest.fn(), })); diff --git a/src/tree/documentdb/IndexesItem.ts b/src/tree/documentdb/IndexesItem.ts index 1825f2b89..f8a55813f 100644 --- a/src/tree/documentdb/IndexesItem.ts +++ b/src/tree/documentdb/IndexesItem.ts @@ -14,7 +14,7 @@ import { } from '../../documentdb/ClustersClient'; import { type Experience } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; -import { meterSilentCatch } from '../../utils/callWithAccumulatingTelemetry'; +import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { getCountPrefix } from '../../utils/countPrefix'; import { type BaseClusterModel, type TreeCluster } from '../models/BaseClusterModel'; import { type TreeElement } from '../TreeElement'; diff --git a/src/utils/accumulatingTelemetry.test.ts b/src/utils/accumulatingTelemetry.test.ts new file mode 100644 index 000000000..c8a486ec2 --- /dev/null +++ b/src/utils/accumulatingTelemetry.test.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { + AUTO_DURATION_DISTRIBUTION_KEY, + accumulateTelemetry, + flushAccumulatedTelemetry, +} from './accumulatingTelemetry'; + +// Records the measurements snapshot each time the telemetry pipeline is entered +// (which, after the R766-P05 redesign, happens only on flush and on the rare +// populator-error path) so tests can inspect what a flush emitted. +const emitted: Array<Record<string, number | undefined>> = []; + +// The mock runs the callback synchronously so the emitted snapshot is available +// immediately after a synchronous `flushAccumulatedTelemetry(...)` call (no +// microtask hop). The real helper's flush callback is synchronous, so this is a +// faithful stand-in. +jest.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: jest.fn((_eventName: string, callback: (context: IActionContext) => unknown) => { + const ctx = { + telemetry: { properties: {}, measurements: {}, suppressAll: false }, + errorHandling: { suppressDisplay: false }, + } as unknown as IActionContext; + try { + const result = callback(ctx); + emitted.push({ ...ctx.telemetry.measurements }); + return Promise.resolve(result); + } catch { + // Mirror the real helper: errors are caught/reported, not rethrown, + // and nothing is emitted for a throwing callback. + return Promise.resolve(undefined); + } + }), +})); + +const mockCallWith = callWithTelemetryAndErrorHandling as jest.Mock; + +describe('accumulateTelemetry', () => { + beforeEach(() => { + emitted.length = 0; + // Clears recorded calls between tests but keeps the mock implementation. + jest.clearAllMocks(); + }); + + function findFlush(measurementKey: string): Record<string, number | undefined> | undefined { + return emitted.find((m) => measurementKey in m); + } + + it('sums numeric measurements across the batch', () => { + const id = 'test.counter'; + for (let i = 0; i < 20; i++) { + accumulateTelemetry(id, (sample) => { + sample.measurements.hits = 1; + }); + } + flushAccumulatedTelemetry(id); + + const flush = findFlush('hits'); + expect(flush).toBeDefined(); + expect(flush?.hits).toBe(20); + }); + + it('records caller-provided distribution gauges as min/max/sum/count', () => { + const id = 'test.callerGauge'; + for (let i = 0; i < 20; i++) { + accumulateTelemetry(id, (sample) => { + sample.distributions.candidateCount = i; + }); + } + flushAccumulatedTelemetry(id); + + const flush = findFlush('dist_candidateCount_count'); + expect(flush).toBeDefined(); + expect(flush?.dist_candidateCount_min).toBe(0); + expect(flush?.dist_candidateCount_max).toBe(19); + expect(flush?.dist_candidateCount_sum).toBe(190); + expect(flush?.dist_candidateCount_count).toBe(20); + }); + + it('automatically records per-call duration with no caller bookkeeping', () => { + const id = 'test.autoDuration'; + for (let i = 0; i < 20; i++) { + accumulateTelemetry(id, () => { + // Caller records nothing; duration must still be captured. + }); + } + flushAccumulatedTelemetry(id); + + const countKey = `dist_${AUTO_DURATION_DISTRIBUTION_KEY}_count`; + const flush = findFlush(countKey); + expect(flush).toBeDefined(); + expect(flush?.[countKey]).toBe(20); + // Duration values are non-negative wall-clock measurements. + expect(flush?.[`dist_${AUTO_DURATION_DISTRIBUTION_KEY}_min`]).toBeGreaterThanOrEqual(0); + expect(flush?.[`dist_${AUTO_DURATION_DISTRIBUTION_KEY}_sum`]).toBeGreaterThanOrEqual(0); + }); + + it('skips non-finite numbers so a stray NaN/Infinity cannot poison the batch', () => { + const id = 'test.finiteGuard'; + for (let i = 0; i < 20; i++) { + accumulateTelemetry(id, (sample) => { + sample.measurements.hits = i === 0 ? Number.NaN : 1; // one bad value + sample.distributions.gauge = i === 1 ? Number.POSITIVE_INFINITY : 5; + }); + } + flushAccumulatedTelemetry(id); + + const flush = findFlush('hits'); + expect(flush).toBeDefined(); + // 19 finite `1`s summed; the NaN call is skipped, not added. + expect(flush?.hits).toBe(19); + // 19 finite `5`s recorded; the Infinity sample is skipped. + expect(flush?.dist_gauge_count).toBe(19); + expect(flush?.dist_gauge_max).toBe(5); + }); + + it('does not enter the telemetry pipeline on the per-call path — only on flush (R766-P05)', () => { + const id = 'test.cheapPath'; + // Stay below the default batch size so no auto-flush fires. + for (let i = 0; i < 5; i++) { + accumulateTelemetry(id, (sample) => { + sample.measurements.hits = 1; + }); + } + // The whole point of the redesign: the accumulate path is pure in-memory + // work and never opens a telemetry/error scope. + expect(mockCallWith).not.toHaveBeenCalled(); + + flushAccumulatedTelemetry(id); + + // The heavy wrapper is entered exactly once, on flush. + expect(mockCallWith).toHaveBeenCalledTimes(1); + }); + + it('does not accumulate when the populator throws, and reports the error once', () => { + const id = 'test.errorsNeverBatch'; + accumulateTelemetry(id, () => { + throw new Error('boom'); + }); + + // The throw is reported once through the standard pipeline, under the + // same event id, without accumulating anything. + expect(mockCallWith).toHaveBeenCalledTimes(1); + expect(mockCallWith.mock.calls[0][0]).toBe(id); + + flushAccumulatedTelemetry(id); + const countKey = `dist_${AUTO_DURATION_DISTRIBUTION_KEY}_count`; + expect(findFlush(countKey)).toBeUndefined(); + }); +}); diff --git a/src/utils/accumulatingTelemetry.ts b/src/utils/accumulatingTelemetry.ts new file mode 100644 index 000000000..b5e1da340 --- /dev/null +++ b/src/utils/accumulatingTelemetry.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; + +/** + * The lightweight bag a caller populates on each accumulating-telemetry call. + * + * This is a plain object — **not** an `IActionContext`. The per-call path no + * longer opens a telemetry/error-handling scope (that only happens once per + * flush), so the populator writes into these three records directly and returns. + * Everything is folded into the running batch totals in memory: + * + * - `measurements` — **summed** across the batch. Use for counters + * (`sample.measurements.hits = 1`). + * - `properties` — **last-wins** across the batch (overwritten each call). Use + * for stable metadata only (e.g. a session id or version), never to bucket + * data — encode buckets into a measurement key instead. + * - `distributions` — reduced to **min / max / sum / count** across the batch. + * Use for gauges (candidate counts, latencies, sizes). + * + * ```ts + * accumulateTelemetry('myEvent', (sample) => { + * sample.measurements.hits = 1; // counter → summed + * sample.distributions.candidateCount = candidates.length; // gauge → min/max/sum/count + * }); + * ``` + * + * Note: on flush each distribution key `foo` is written to the flushed event's + * measurements as `dist_foo_min/max/sum/count`. The `dist_` measurement-key + * prefix is therefore reserved; do not set measurements with that shape manually + * or they will be overwritten by the distribution rollup. + */ +export interface TelemetrySample { + /** Counters — summed across the batch. */ + measurements: Record<string, number>; + /** Stable metadata — last value wins across the batch. */ + properties: Record<string, string>; + /** Gauges — reduced to min / max / sum / count across the batch. */ + distributions: Record<string, number>; +} + +/** + * Accumulated stats for a single distribution key across a batch. + */ +interface DistributionAccumulator { + min: number; + max: number; + sum: number; + count: number; +} + +/** + * Reserved distribution key under which every call automatically records the + * wall-clock duration (in milliseconds) of the **populator callback**. Collected + * by the helper itself, so every call site gets a latency min / max / sum / count + * for free without any caller bookkeeping. On flush it surfaces as + * `dist_auto_duration_ms_min`, `_max`, `_sum`, `_count`. + * + * Note: the populator is synchronous and normally just sets a few keys, so this + * duration is tiny by design. To time real (e.g. async) work, measure it in the + * caller and write the result into `sample.distributions` under your own key. + */ +export const AUTO_DURATION_DISTRIBUTION_KEY = 'auto_duration_ms'; + +/** + * Options controlling how `accumulateTelemetry` batches events. + */ +export interface AccumulateTelemetryOptions { + /** + * How many accumulated calls trigger a flush attempt. + * + * @default 20 + */ + batchSize?: number; + + /** + * Minimum ms between flushes. If `batchSize` is hit sooner, we keep + * accumulating and retry on the next call. + * + * @default 30_000 + */ + minFlushIntervalMs?: number; +} + +interface AccumulatorState { + batchSize: number; + minFlushIntervalMs: number; + sinceLastFlush: number; + lastFlushTime: number; + measurements: Record<string, number>; + properties: Record<string, string>; + distributions: Record<string, DistributionAccumulator>; +} + +const accumulators = new Map<string, AccumulatorState>(); + +function getOrCreateState(callbackId: string, options: AccumulateTelemetryOptions | undefined): AccumulatorState { + let state = accumulators.get(callbackId); + if (!state) { + state = { + batchSize: options?.batchSize ?? 20, + minFlushIntervalMs: options?.minFlushIntervalMs ?? 30_000, + sinceLastFlush: 0, + lastFlushTime: 0, + measurements: {}, + properties: {}, + distributions: {}, + }; + accumulators.set(callbackId, state); + } + return state; +} + +/** + * Accumulate a telemetry sample under `callbackId` and flush the running totals + * as a single event once a batch fills. + * + * **Cheap per call, heavy only on flush.** The populator runs synchronously + * against a plain {@link TelemetrySample} bag — there is **no** `IActionContext`, + * **no** `await`, and **no** `callWithTelemetryAndErrorHandling` on the per-call + * path. Its values are folded into in-memory batch totals. The Azure telemetry + * pipeline is entered exactly once, on flush, with the rolled-up event. This is + * what makes the helper safe to call on hot paths (e.g. per webview RPC, per + * keystroke) where the old per-call wrapper cost added up. + * + * Mental model: each call contributes to a running total. + * - `sample.measurements` values are **summed** across calls. Use for counters + * (`sample.measurements.myCounter = 1`). + * - `sample.distributions` values are tracked as **distribution metrics** + * (min / max / sum / count) across the batch. Use for gauges like candidate + * counts, latencies, or sizes. On flush each key is emitted as four measurement + * fields: `dist_{key}_min`, `dist_{key}_max`, `dist_{key}_sum`, `dist_{key}_count`. + * - Every call automatically contributes the populator's own wall-clock duration + * to the `auto_duration_ms` distribution (see {@link AUTO_DURATION_DISTRIBUTION_KEY}). + * - `sample.properties` values are **last-wins** (overwritten on each call). Use + * for stable metadata only (e.g. session id, version). Do NOT use properties to + * bucket data — encode the bucket into a measurement key instead: + * `sample.measurements[`cat_${category}`] = 1`. + * + * Behavior: + * - Flushes every `batchSize` calls with a `minFlushIntervalMs` throttle. + * - Flushed event name is exactly `callbackId` (same as the non-accumulating + * variant — no `.batch` suffix, no schema split). + * - Errors NEVER accumulate: if the populator throws, the sample is discarded + * (not folded into the batch) and the throw is reported once through the + * standard `callWithTelemetryAndErrorHandling` pipeline under `callbackId`. The + * accumulator state is untouched. This heavy path runs only on the (rare) + * throw, so it does not affect the cheap happy path. + * - Fire-and-forget: returns `void`. There is no per-call promise to await + * because the per-call path does no async work. + */ +export function accumulateTelemetry( + callbackId: string, + populate: (sample: TelemetrySample) => void, + options?: AccumulateTelemetryOptions, +): void { + const state = getOrCreateState(callbackId, options); + + // Cheap per-call path: run the populator against a plain bag, synchronously. + const sample: TelemetrySample = { measurements: {}, properties: {}, distributions: {} }; + const startTime = performance.now(); + try { + populate(sample); + } catch (error) { + // The populator threw — a programming error, and rare. Discard the + // (partial) sample so a failed call never corrupts the batch, and surface + // the error through the standard telemetry/error pipeline exactly as the + // former per-call implementation did (same event name). Only this rare + // error path pays the heavy wrapper cost. + void callWithTelemetryAndErrorHandling(callbackId, () => { + throw error; + }); + return; + } + const durationMs = performance.now() - startTime; + + // Always record the populator's own duration as a distribution. A caller + // value under the reserved key (if any) does not override this; the helper's + // measured duration wins. + if (Number.isFinite(durationMs)) { + sample.distributions[AUTO_DURATION_DISTRIBUTION_KEY] = durationMs; + } + + accumulate(callbackId, state, sample); +} + +/** + * Fold one {@link TelemetrySample} into the running batch `state`: measurements + * are summed, properties are last-wins, and distributions are reduced to + * min / max / sum / count. Non-finite numbers are skipped defensively so a stray + * `NaN` / `Infinity` from a caller cannot poison a sum or a min/max reduction. + * Bumps the batch counter and flushes when both the size and interval gates pass. + */ +function accumulate(callbackId: string, state: AccumulatorState, sample: TelemetrySample): void { + for (const [k, v] of Object.entries(sample.measurements)) { + if (Number.isFinite(v)) { + state.measurements[k] = (state.measurements[k] ?? 0) + v; + } + } + for (const [k, v] of Object.entries(sample.properties)) { + state.properties[k] = v; // last-wins + } + for (const [k, v] of Object.entries(sample.distributions)) { + if (!Number.isFinite(v)) { + continue; + } + const acc = state.distributions[k]; + if (acc) { + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count++; + } else { + state.distributions[k] = { min: v, max: v, sum: v, count: 1 }; + } + } + state.sinceLastFlush++; + + if (state.sinceLastFlush >= state.batchSize) { + const now = Date.now(); + if (now - state.lastFlushTime >= state.minFlushIntervalMs) { + flushState(callbackId, state, now); + } + // Otherwise keep accumulating; the next call will re-check. + } +} + +function flushState(callbackId: string, state: AccumulatorState, now: number): void { + const measurementKeys = Object.keys(state.measurements); + const propertyKeys = Object.keys(state.properties); + const distributionKeys = Object.keys(state.distributions); + if (measurementKeys.length === 0 && propertyKeys.length === 0 && distributionKeys.length === 0) { + return; + } + + const measurementsSnapshot = state.measurements; + const propertiesSnapshot = state.properties; + const distributionsSnapshot = state.distributions; + state.measurements = {}; + state.properties = {}; + state.distributions = {}; + state.sinceLastFlush = 0; + state.lastFlushTime = now; + + void callWithTelemetryAndErrorHandling(callbackId, (ctx) => { + ctx.errorHandling.suppressDisplay = true; + for (const [k, v] of Object.entries(measurementsSnapshot)) { + ctx.telemetry.measurements[k] = v; + } + for (const [k, v] of Object.entries(propertiesSnapshot)) { + ctx.telemetry.properties[k] = v; + } + for (const [k, v] of Object.entries(distributionsSnapshot)) { + ctx.telemetry.measurements[`dist_${k}_min`] = v.min; + ctx.telemetry.measurements[`dist_${k}_max`] = v.max; + ctx.telemetry.measurements[`dist_${k}_sum`] = v.sum; + ctx.telemetry.measurements[`dist_${k}_count`] = v.count; + } + }); +} + +/** + * Force-flush accumulated totals to telemetry. Pass a specific `callbackId` + * to flush just that accumulator, or omit it to flush all registered ones + * (e.g., on extension deactivation). + */ +export function flushAccumulatedTelemetry(callbackId?: string): void { + const now = Date.now(); + if (callbackId !== undefined) { + const state = accumulators.get(callbackId); + if (state) { + flushState(callbackId, state, now); + } + return; + } + for (const [id, state] of accumulators) { + flushState(id, state, now); + } +} + +/** + * Shorthand for silent-catch metering. + * + * Counts a hit under the shared event `silentCatch`, keyed by + * `accumulated_<locationKey>`. The `accumulated_` prefix prevents collision + * with framework-injected measurement names (e.g. `duration`) and keeps the + * discovery query clean: + * + * ```kql + * customEvents + * | where name == "documentDB/silentCatch" + * | mv-expand m = customMeasurements + * | where tostring(m.key) startswith "accumulated_" + * | summarize sum(todouble(m.value)) by tostring(m.key) + * ``` + * + * Usage: + * ```ts + * catch { + * meterSilentCatch('feedResultToSchemaStore_ejson'); + * } + * ``` + */ +export function meterSilentCatch(locationKey: string): void { + accumulateTelemetry('silentCatch', (sample) => { + sample.measurements[`accumulated_${locationKey}`] = 1; + }); +} diff --git a/src/utils/callWithAccumulatingTelemetry.test.ts b/src/utils/callWithAccumulatingTelemetry.test.ts deleted file mode 100644 index 592498f0f..000000000 --- a/src/utils/callWithAccumulatingTelemetry.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { - AUTO_DURATION_DISTRIBUTION_KEY, - callWithAccumulatingTelemetry, - flushAccumulatingTelemetry, - type TelemetryWithDistributions, -} from './callWithAccumulatingTelemetry'; - -// Each invocation gets a fresh context; we record the measurements snapshot -// after the callback runs so we can inspect what a flush emitted. -const emitted: Array<Record<string, number | undefined>> = []; - -jest.mock('@microsoft/vscode-azext-utils', () => ({ - callWithTelemetryAndErrorHandling: jest.fn( - async (_eventName: string, callback: (context: IActionContext) => unknown) => { - const ctx = { - telemetry: { properties: {}, measurements: {}, suppressAll: false }, - errorHandling: { suppressDisplay: false }, - } as unknown as IActionContext; - try { - // Mirror the real helper: swallow callback errors and return undefined. - const result = await callback(ctx); - // Per-call events set suppressAll; only the flush event actually emits. - if (!ctx.telemetry.suppressAll) { - emitted.push({ ...ctx.telemetry.measurements }); - } - return result; - } catch { - return undefined; - } - }, - ), -})); - -describe('callWithAccumulatingTelemetry', () => { - beforeEach(() => { - emitted.length = 0; - }); - - function findFlush(measurementKey: string): Record<string, number | undefined> | undefined { - return emitted.find((m) => measurementKey in m); - } - - it('sums numeric measurements across the batch', async () => { - const id = 'test.counter'; - for (let i = 0; i < 20; i++) { - await callWithAccumulatingTelemetry(id, (ctx) => { - ctx.telemetry.measurements.hits = 1; - }); - } - flushAccumulatingTelemetry(id); - - const flush = findFlush('hits'); - expect(flush).toBeDefined(); - expect(flush?.hits).toBe(20); - }); - - it('records caller-provided distribution gauges as min/max/sum/count', async () => { - const id = 'test.callerGauge'; - for (let i = 0; i < 20; i++) { - await callWithAccumulatingTelemetry(id, (ctx) => { - (ctx.telemetry as TelemetryWithDistributions).distributions.candidateCount = i; - }); - } - flushAccumulatingTelemetry(id); - - const flush = findFlush('dist_candidateCount_count'); - expect(flush).toBeDefined(); - expect(flush?.dist_candidateCount_min).toBe(0); - expect(flush?.dist_candidateCount_max).toBe(19); - expect(flush?.dist_candidateCount_sum).toBe(190); - expect(flush?.dist_candidateCount_count).toBe(20); - }); - - it('automatically records per-call duration with no caller bookkeeping', async () => { - const id = 'test.autoDuration'; - for (let i = 0; i < 20; i++) { - await callWithAccumulatingTelemetry(id, () => { - // Caller records nothing; duration must still be captured. - }); - } - flushAccumulatingTelemetry(id); - - const countKey = `dist_${AUTO_DURATION_DISTRIBUTION_KEY}_count`; - const flush = findFlush(countKey); - expect(flush).toBeDefined(); - expect(flush?.[countKey]).toBe(20); - // Duration values are non-negative wall-clock measurements. - expect(flush?.[`dist_${AUTO_DURATION_DISTRIBUTION_KEY}_min`]).toBeGreaterThanOrEqual(0); - expect(flush?.[`dist_${AUTO_DURATION_DISTRIBUTION_KEY}_sum`]).toBeGreaterThanOrEqual(0); - }); - - it('does not accumulate when the callback throws', async () => { - const id = 'test.errorsNeverBatch'; - await expect( - callWithAccumulatingTelemetry(id, () => { - throw new Error('boom'); - }), - ).resolves.toBeUndefined(); - flushAccumulatingTelemetry(id); - - const countKey = `dist_${AUTO_DURATION_DISTRIBUTION_KEY}_count`; - expect(findFlush(countKey)).toBeUndefined(); - }); -}); diff --git a/src/utils/callWithAccumulatingTelemetry.ts b/src/utils/callWithAccumulatingTelemetry.ts deleted file mode 100644 index ddab7054c..000000000 --- a/src/utils/callWithAccumulatingTelemetry.ts +++ /dev/null @@ -1,315 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; - -/** - * Bag attached to `ctx.telemetry.distributions` during an accumulating - * telemetry callback. Each key records a numeric value that will be reduced - * to min / max / sum / count across the batch. - * - * ```ts - * callWithAccumulatingTelemetry('myEvent', (ctx) => { - * const t = ctx.telemetry as TelemetryWithDistributions; - * t.distributions.candidateCount = candidates.length; - * }); - * ``` - * - * Note: on flush each distribution key `foo` is written to - * `ctx.telemetry.measurements` as `dist_foo_min/max/sum/count`. The `dist_` - * measurement-key prefix is therefore reserved; do not set measurements with - * that shape manually or they will be overwritten by the distribution rollup. - */ -export type TelemetryWithDistributions = IActionContext['telemetry'] & { - distributions: Record<string, number>; -}; - -/** - * Accumulated stats for a single distribution key across a batch. - */ -interface DistributionAccumulator { - min: number; - max: number; - sum: number; - count: number; -} - -/** - * Reserved distribution key under which every successful call automatically - * records its own wall-clock duration (in milliseconds). This is collected by - * the wrapper itself, so every call site gets latency min / max / sum / count - * for free without any caller bookkeeping. On flush it surfaces as - * `dist_auto_duration_ms_min`, `_max`, `_sum`, `_count`. - */ -export const AUTO_DURATION_DISTRIBUTION_KEY = 'auto_duration_ms'; - -/** - * Options controlling how `callWithAccumulatingTelemetry` batches events. - */ -export interface AccumulatingTelemetryOptions { - /** - * How many accumulated calls trigger a flush attempt. - * - * @default 20 - */ - batchSize?: number; - - /** - * Minimum ms between flushes. If `batchSize` is hit sooner, we keep - * accumulating and retry on the next call. - * - * @default 30_000 - */ - minFlushIntervalMs?: number; -} - -interface AccumulatorState { - batchSize: number; - minFlushIntervalMs: number; - sinceLastFlush: number; - lastFlushTime: number; - measurements: Record<string, number>; - properties: Record<string, string>; - distributions: Record<string, DistributionAccumulator>; -} - -const accumulators = new Map<string, AccumulatorState>(); - -function getOrCreateState(callbackId: string, options: AccumulatingTelemetryOptions | undefined): AccumulatorState { - let state = accumulators.get(callbackId); - if (!state) { - state = { - batchSize: options?.batchSize ?? 20, - minFlushIntervalMs: options?.minFlushIntervalMs ?? 30_000, - sinceLastFlush: 0, - lastFlushTime: 0, - measurements: {}, - properties: {}, - distributions: {}, - }; - accumulators.set(callbackId, state); - } - return state; -} - -/** - * Like `callWithTelemetryAndErrorHandling`, but accumulates successful events - * under the same `callbackId` and flushes them as a single telemetry event. - * - * Mental model: each call contributes to a running total. - * - Numeric values on `context.telemetry.measurements` are **summed** across - * calls. Use this for counters (`measurements.myCounter = 1`). - * - Numeric values on `context.telemetry.distributions` are tracked as - * **distribution metrics** (min / max / sum / count) across the batch. - * Use this for gauges like candidate counts, latencies, or sizes. - * On flush each key is emitted as four measurement fields: - * `dist_{key}_min`, `dist_{key}_max`, `dist_{key}_sum`, `dist_{key}_count`. - * - Every successful call automatically contributes its own wall-clock - * duration to the `auto_duration_ms` distribution, with no caller code. - * This surfaces as `dist_auto_duration_ms_min/max/sum/count` on flush, - * giving per-event latency for free at every call site. - * - String values on `context.telemetry.properties` are **last-wins** - * (overwritten on each call). Use this for stable metadata only - * (e.g., session id, version). Do NOT use properties to bucket data — - * encode the bucket into a measurement key instead: - * `ctx.telemetry.measurements[`cat_${category}`] = 1` - * - * Behavior: - * - Flushes every `batchSize` calls with a `minFlushIntervalMs` throttle. - * - Flushed event name is exactly `callbackId` (same as the non-accumulating - * variant — no `.batch` suffix, no schema split). - * - Errors NEVER accumulate: if the callback throws, the error flows through - * the standard `callWithTelemetryAndErrorHandling` pipeline and the - * accumulator state is untouched. - * - The callback's return value passes through. - */ -export async function callWithAccumulatingTelemetry<T>( - callbackId: string, - callback: (context: IActionContext) => T | PromiseLike<T>, - options?: AccumulatingTelemetryOptions, -): Promise<T | undefined> { - const state = getOrCreateState(callbackId, options); - - let capturedMeasurements: Record<string, number> | undefined; - let capturedProperties: Record<string, string> | undefined; - let capturedDistributions: Record<string, number> | undefined; - - const result = await callWithTelemetryAndErrorHandling(callbackId, async (ctx) => { - ctx.errorHandling.suppressDisplay = true; - - // Attach the distributions bag to ctx.telemetry so callers can write - // gauges that will be reduced across the batch. - (ctx.telemetry as TelemetryWithDistributions).distributions = {}; - - // Run the user callback first. If it throws, suppressAll stays false - // and the error flows through the standard pipeline (errors never batch). - // Time the call so we can record its duration automatically below. - const startTime = performance.now(); - const value = await callback(ctx); - const durationMs = performance.now() - startTime; - - // Success path: capture measurements/properties and suppress per-call emit. - const m: Record<string, number> = {}; - for (const [k, v] of Object.entries(ctx.telemetry.measurements)) { - if (typeof v === 'number' && Number.isFinite(v)) { - m[k] = v; - } - } - capturedMeasurements = m; - - const p: Record<string, string> = {}; - for (const [k, v] of Object.entries(ctx.telemetry.properties)) { - if (typeof v === 'string') { - p[k] = v; - } - } - capturedProperties = p; - - const d: Record<string, number> = {}; - const dist = (ctx.telemetry as TelemetryWithDistributions).distributions; - if (dist && typeof dist === 'object') { - for (const [k, v] of Object.entries(dist)) { - if (typeof v === 'number' && Number.isFinite(v)) { - d[k] = v; - } - } - } - // Always record the call's own duration as a distribution. A caller - // value under the reserved key (if any) does not override this; the - // wrapper's measured duration wins. - if (Number.isFinite(durationMs)) { - d[AUTO_DURATION_DISTRIBUTION_KEY] = durationMs; - } - capturedDistributions = Object.keys(d).length ? d : undefined; - - ctx.telemetry.suppressAll = true; - return value; - }); - - if (capturedMeasurements || capturedProperties || capturedDistributions) { - accumulate(callbackId, state, capturedMeasurements ?? {}, capturedProperties ?? {}, capturedDistributions); - } - - return result; -} - -function accumulate( - callbackId: string, - state: AccumulatorState, - measurements: Record<string, number>, - properties: Record<string, string>, - distributions?: Record<string, number>, -): void { - for (const [k, v] of Object.entries(measurements)) { - state.measurements[k] = (state.measurements[k] ?? 0) + v; - } - for (const [k, v] of Object.entries(properties)) { - state.properties[k] = v; // last-wins - } - if (distributions) { - for (const [k, v] of Object.entries(distributions)) { - const acc = state.distributions[k]; - if (acc) { - acc.min = Math.min(acc.min, v); - acc.max = Math.max(acc.max, v); - acc.sum += v; - acc.count++; - } else { - state.distributions[k] = { min: v, max: v, sum: v, count: 1 }; - } - } - } - state.sinceLastFlush++; - - if (state.sinceLastFlush >= state.batchSize) { - const now = Date.now(); - if (now - state.lastFlushTime >= state.minFlushIntervalMs) { - flushState(callbackId, state, now); - } - // Otherwise keep accumulating; the next call will re-check. - } -} - -function flushState(callbackId: string, state: AccumulatorState, now: number): void { - const measurementKeys = Object.keys(state.measurements); - const propertyKeys = Object.keys(state.properties); - const distributionKeys = Object.keys(state.distributions); - if (measurementKeys.length === 0 && propertyKeys.length === 0 && distributionKeys.length === 0) { - return; - } - - const measurementsSnapshot = state.measurements; - const propertiesSnapshot = state.properties; - const distributionsSnapshot = state.distributions; - state.measurements = {}; - state.properties = {}; - state.distributions = {}; - state.sinceLastFlush = 0; - state.lastFlushTime = now; - - void callWithTelemetryAndErrorHandling(callbackId, (ctx) => { - ctx.errorHandling.suppressDisplay = true; - for (const [k, v] of Object.entries(measurementsSnapshot)) { - ctx.telemetry.measurements[k] = v; - } - for (const [k, v] of Object.entries(propertiesSnapshot)) { - ctx.telemetry.properties[k] = v; - } - for (const [k, v] of Object.entries(distributionsSnapshot)) { - ctx.telemetry.measurements[`dist_${k}_min`] = v.min; - ctx.telemetry.measurements[`dist_${k}_max`] = v.max; - ctx.telemetry.measurements[`dist_${k}_sum`] = v.sum; - ctx.telemetry.measurements[`dist_${k}_count`] = v.count; - } - }); -} - -/** - * Force-flush accumulated totals to telemetry. Pass a specific `callbackId` - * to flush just that accumulator, or omit it to flush all registered ones - * (e.g., on extension deactivation). - */ -export function flushAccumulatingTelemetry(callbackId?: string): void { - const now = Date.now(); - if (callbackId !== undefined) { - const state = accumulators.get(callbackId); - if (state) { - flushState(callbackId, state, now); - } - return; - } - for (const [id, state] of accumulators) { - flushState(id, state, now); - } -} - -/** - * Shorthand for silent-catch metering. - * - * Counts a hit under the shared event `silentCatch`, keyed by - * `accumulated_<locationKey>`. The `accumulated_` prefix prevents collision - * with framework-injected measurement names (e.g. `duration`) and keeps the - * discovery query clean: - * - * ```kql - * customEvents - * | where name == "documentDB/silentCatch" - * | mv-expand m = customMeasurements - * | where tostring(m.key) startswith "accumulated_" - * | summarize sum(todouble(m.value)) by tostring(m.key) - * ``` - * - * Usage: - * ```ts - * catch { - * meterSilentCatch('feedResultToSchemaStore_ejson'); - * } - * ``` - */ -export function meterSilentCatch(locationKey: string): void { - void callWithAccumulatingTelemetry('silentCatch', (ctx) => { - ctx.telemetry.measurements[`accumulated_${locationKey}`] = 1; - }); -} diff --git a/src/webviews/_integration/README.md b/src/webviews/_integration/README.md index 70ab45cfd..b2b9fb7c3 100644 --- a/src/webviews/_integration/README.md +++ b/src/webviews/_integration/README.md @@ -1,42 +1,47 @@ # `src/webviews/_integration/` Local integration layer between the DocumentDB extension and -[`@microsoft/vscode-ext-react-webview`](../../../packages/vscode-ext-react-webview/README.md). +[`@microsoft/vscode-ext-webview`](../../../packages/vscode-ext-webview/README.md). > This folder is **not** the extension's public API. It is the -> consumer-owned glue that wires the framework package (tRPC transport + -> base `WebviewController`) into this extension's bundle layout, telemetry -> pipeline, and webview registry. +> consumer-owned glue that wires the framework package (tRPC transport, the +> `openWebview` factory, and pluggable telemetry) into this extension's +> bundle layout, telemetry pipeline, and webview registry. ## Files -| File | Owns | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `configuration.ts` | Consumer-owned knobs: telemetry namespace and prefixes, bundle layout, dev-server host | -| `appRouter.ts` | Root tRPC router, telemetry middleware, `publicProcedureWithTelemetry`, `WithTelemetry`, `BaseRouterContext`, and common procedures | -| `useTrpcClient.ts` | React hook pre-bound to `AppRouter` (browser-side glue) | -| `WebviewControllerBase.ts` | DocumentDB controller base class; pre-fills bundle layout and dev-server host | -| `WebviewRegistry.ts` | Webview name to React component map; source of the `WebviewName` union | +| File | Side | Purpose | +| -------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `configuration.ts` | host | Single home for consumer-owned knobs: the telemetry namespace and event prefixes, the bundle-vs-dev source layout, and the dev-server host. Other files import a slice of `WEBVIEW_CONFIG` rather than hard-coding these. | +| `trpc.ts` | host | The extension's one tRPC instance (`initWebviewTrpc`). Exports `publicProcedure`, `router`, `createCallerFactory`, `publicProcedureWithTelemetry`, and the `WithTelemetry` helper, and owns the DocumentDB `TelemetryRunner` that reports RPC telemetry to Application Insights. Kept as a leaf module so per-view routers can import it without a circular dependency on `appRouter.ts`. | +| `appRouter.ts` | host | The root tRPC router (the `common` router plus each per-view router), the DocumentDB `BaseRouterContext`, and the shared `common` procedures (report event, report error, display error, survey ping/open, open URL). Imports its primitives from `trpc.ts`. | +| `openAppWebview.ts` | host | DocumentDB preset over the package `openWebview` factory. Pre-fills `appRouter`, `createCallerFactory`, the telemetry logger, and the `configuration.ts` layout, so each per-view factory only passes what is unique to its view. This is the function-shaped replacement for the former `WebviewControllerBase` class. | +| `useTrpcClient.ts` | webview | React hook pre-typed to `AppRouter`, so components call `useTrpcClient()` without repeating the router type argument. | +| `WebviewRegistry.ts` | webview value + host type | Maps each webview name to its React root component (read by `render()` in `src/webviews/index.tsx`) and is the source of the `WebviewName` union (used host-side by `openAppWebview`). See the file's own comment for how to add an entry. | +| `observability/` | host + webview | DocumentDB's opt-in observability sinks that specialize the framework's generic defaults into this extension's telemetry and error pipelines: `rpcConcurrencyLogger.ts` (host) and `reportObserverError.ts` (webview). See [`observability/README.md`](./observability/README.md). | Per-view router files (`collectionViewRouter.ts`, `documentsViewRouter.ts`) live next to their views, not here. See "Per-view router convention" below. ## When you want to X, edit Y -| Task | Edit | -| ----------------------------------------------------- | ----------------------------------------------------------- | -| Add a new webview | `WebviewRegistry.ts` (and register the controller command) | -| Add a tRPC procedure to an existing view | `<view>Router.ts` next to the view | -| Bundle a new per-view router into the app router tree | `appRouter.ts` | -| Change the telemetry sink or RPC event namespace | `configuration.ts` (event prefixes) + `appRouter.ts` (sink) | -| Add a field to the per-procedure context | `BaseRouterContext` in `appRouter.ts` | -| Change the bundle layout or dev-server host | `configuration.ts` | +| Task | Edit | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Add a new webview | `WebviewRegistry.ts`, a per-view factory that calls `openAppWebview`, and the open command | +| Add a tRPC procedure to an existing view | `<view>Router.ts` next to the view | +| Bundle a new per-view router into the app router tree | `appRouter.ts` | +| Change the telemetry sink or RPC event namespace | `configuration.ts` (event prefixes) + `trpc.ts` (the `TelemetryRunner` sink) | +| Add a field to the per-procedure context | `BaseRouterContext` in `appRouter.ts` | +| Change the bundle layout or dev-server host | `configuration.ts` | +| Change how RPC concurrency is logged / sampled | `observability/rpcConcurrencyLogger.ts` | +| Change how webview event-observer errors are reported | `observability/reportObserverError.ts` | ## Data flow -1. **Extension host:** a view controller extends - `WebviewControllerBase` (this folder) and is constructed in response to - a user command. The framework wires `appRouter` to the webview panel. +1. **Extension host:** a per-view factory (e.g. `openCollectionWebview`) + calls `openAppWebview(...)` in response to a user command. That opens the + panel, renders its HTML, and wires `appRouter` to it through the framework's + `openWebview` factory. 2. **Transport:** `vscodeLink` (from the framework package) marshals tRPC calls over `postMessage` between host and webview. 3. **Webview (browser):** React components call `useTrpcClient()` (this @@ -60,5 +65,10 @@ Each per-view router: - Is wired into the root tRPC tree in `appRouter.ts` so it is reachable from the webview client. -When the surface in this folder is reshaped (planned for beta), the README -will be updated in lock step. +This folder was reshaped for the `@microsoft/vscode-ext-webview` redesign: the +former `WebviewControllerBase` class became the `openAppWebview` factory, and +the telemetry sink moved into `trpc.ts`. The DocumentDB observability adapters +(`rpcConcurrencyLogger`, `reportObserverError`) then moved into the +`observability/` subfolder to keep this top level focused on the router and +transport wiring. Keep this README in lock step whenever the surface changes +again. diff --git a/src/webviews/_integration/WebviewControllerBase.ts b/src/webviews/_integration/WebviewControllerBase.ts deleted file mode 100644 index fedb0db4d..000000000 --- a/src/webviews/_integration/WebviewControllerBase.ts +++ /dev/null @@ -1,68 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * DocumentDB-flavoured `WebviewControllerBase`. - * - * Thin wrapper that pre-fills the framework's - * {@link import('@microsoft/vscode-ext-react-webview').WebviewControllerOptions} - * with this extension's bundle layout, dev-server host, and root tRPC router. - * - * View controllers (`CollectionViewController`, `DocumentsViewController`, - * ...) extend this class and pass only the per-view arguments (title, - * webview name, initial configuration, view column, icon) to `super(...)`. - * - * The `Base` suffix disambiguates this DocumentDB-specific base class from - * the framework's `WebviewController` it extends; both appear in import - * graphs and stack traces, and the same-name shadow used to be confusing. - */ - -import { WebviewController } from '@microsoft/vscode-ext-react-webview/server'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { appRouter, type AppRouter, type BaseRouterContext } from './appRouter'; -import { WEBVIEW_CONFIG } from './configuration'; -import { type WebviewName } from './WebviewRegistry'; - -/** - * DocumentDB `WebviewControllerBase`. View controllers extend this and - * only need to forward the call-site arguments to `super(...)`. - * - * @template TConfiguration - The initial configuration object passed to the - * webview (received in the webview via - * `useConfiguration`). - */ -export abstract class WebviewControllerBase<TConfiguration> extends WebviewController< - AppRouter, - TConfiguration, - BaseRouterContext -> { - constructor( - extensionContext: vscode.ExtensionContext, - title: string, - webviewName: WebviewName, - configuration: TConfiguration, - viewColumn: vscode.ViewColumn = vscode.ViewColumn.One, - iconPath?: vscode.Uri | { readonly light: vscode.Uri; readonly dark: vscode.Uri }, - ) { - super( - extensionContext, - title, - webviewName, - configuration, - { - appRouter, - // `ext.isBundle` is assigned in `activate()`; controllers are - // only instantiated in response to user commands, which always - // run after activation, so the value is safe to read here. - isBundled: !!ext.isBundle, - sourceLayout: WEBVIEW_CONFIG.bundle, - devServerHost: WEBVIEW_CONFIG.devServerHost, - }, - viewColumn, - iconPath, - ); - } -} diff --git a/src/webviews/_integration/WebviewRegistry.ts b/src/webviews/_integration/WebviewRegistry.ts index 2765d565d..0be79dcaf 100644 --- a/src/webviews/_integration/WebviewRegistry.ts +++ b/src/webviews/_integration/WebviewRegistry.ts @@ -6,13 +6,44 @@ import { CollectionView } from '../documentdb/collectionView/CollectionView'; import { DocumentView } from '../documentdb/documentView/documentView'; +/** + * Maps each webview name to the React component mounted for it. + * + * ## Why this exists + * + * The whole webview side ships as a single bundle (`views.js`). When a panel + * opens, the framework's generated HTML calls + * `render(viewType, acquireVsCodeApi())` (see `src/webviews/index.tsx`), passing + * the `viewType` the panel was created with. This registry is the lookup that + * turns that string key into the correct root component to render; without it a + * single bundle could not serve more than one panel type. + * + * It is also the single source of the {@link WebviewName} union, which the host + * side (`openAppWebview` / `OpenAppWebviewOptions.webviewName`) uses so a panel + * can only be opened with a name that has a registered component. A typo becomes + * a compile error instead of a blank panel at runtime. + * + * ## How to add a new webview + * + * 1. Create the React root component (e.g. `MyView`). + * 2. Add a line here: `myView: MyView`. The key is the webview's name. + * 3. In the view's factory, pass that same key as `webviewName` to + * `openAppWebview({ webviewName: 'myView', ... })` (see `openCollectionWebview`). + * 4. Register the command that opens the view. + * + * The key here and the `webviewName` passed to `openAppWebview` must match; + * {@link WebviewName} enforces that at compile time. + */ export const WebviewRegistry = { collectionView: CollectionView, documentView: DocumentView, } as const; /** - * Union type of all registered webview name keys. - * Used by WebviewController to ensure the webviewName matches a registered entry. + * Union of all registered webview name keys (e.g. `'collectionView'`). + * + * Used host-side by `openAppWebview` / `OpenAppWebviewOptions.webviewName` to + * constrain which panels can be opened, and webview-side by `render(...)` in + * `src/webviews/index.tsx` to type the lookup key. */ export type WebviewName = keyof typeof WebviewRegistry; diff --git a/src/webviews/_integration/appRouter.ts b/src/webviews/_integration/appRouter.ts index 3446a6ea7..c167d2532 100644 --- a/src/webviews/_integration/appRouter.ts +++ b/src/webviews/_integration/appRouter.ts @@ -23,7 +23,7 @@ */ import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; -import { type BaseRouterContext as FrameworkBaseRouterContext } from '@microsoft/vscode-ext-react-webview/server'; +import { type BaseRouterContext as FrameworkBaseRouterContext } from '@microsoft/vscode-ext-webview'; import * as vscode from 'vscode'; import { z } from 'zod'; import { type API } from '../../DocumentDBExperiences'; @@ -42,7 +42,7 @@ export type { WithTelemetry }; /** * DocumentDB-flavoured router context. Extends the framework's - * `BaseRouterContext` (from `@microsoft/vscode-ext-react-webview`, which + * `BaseRouterContext` (from `@microsoft/vscode-ext-webview`, which * already declares `telemetry?` and `signal?`) with the DocumentDB-specific * fields every procedure needs. Inheriting `telemetry?` / `signal?` keeps * the context shape in lock step with the framework: if the framework adds @@ -72,7 +72,8 @@ export type BaseRouterContext = FrameworkBaseRouterContext & { * (combined with `WEBVIEW_CONFIG.telemetry.webviewEventPrefix` to form * the final event name, e.g. `documentDB.webview.event.${webviewName}.${eventName}`). * - * This is **not** the same as the registry key passed to the `WebviewControllerBase` constructor. + * This is **not** the same as the registry key (`viewType`) passed to the + * `openAppWebview` factory / framework `openWebview` options. */ webviewName: string; }; diff --git a/src/webviews/_integration/configuration.ts b/src/webviews/_integration/configuration.ts index 4631f691b..17aa75641 100644 --- a/src/webviews/_integration/configuration.ts +++ b/src/webviews/_integration/configuration.ts @@ -39,6 +39,13 @@ export const WEBVIEW_CONFIG = { * `${webviewErrorPrefix}.${webviewName}`. */ webviewErrorPrefix: `${TELEMETRY_NAMESPACE}.webview.error`, + /** + * Event name for the batched webview RPC concurrency gauge (R766-S04). + * Emitted through `accumulateTelemetry`, so it surfaces as + * `dist_concurrentRpcOps_min/max/sum/count` (the concurrency gauge) plus a + * summed `dispatch` count and the free `dist_auto_duration_ms_*` latency. + */ + rpcConcurrencyEvent: `${TELEMETRY_NAMESPACE}.webview.rpcConcurrency`, }, /** * Layout describing where the extension's webview JavaScript lives on diff --git a/src/webviews/_integration/observability/README.md b/src/webviews/_integration/observability/README.md new file mode 100644 index 000000000..1a72316c0 --- /dev/null +++ b/src/webviews/_integration/observability/README.md @@ -0,0 +1,30 @@ +# `src/webviews/_integration/observability/` + +DocumentDB's opt-in observability sinks for the +[`@microsoft/vscode-ext-webview`](../../../../packages/vscode-ext-webview/README.md) +package. The framework ships quiet, generic defaults (a console dispatch logger, +an isolated observer-error handler that logs to `console.error`); the files here +are the DocumentDB-specific adapters that elevate those defaults into this +extension's telemetry and error pipelines. + +They live in their own folder because they are cross-cutting plumbing (one host +side, one webview side) rather than part of the router/transport wiring in the +parent folder. Group new logging, telemetry-adapter, or error-reporting sinks +here. + +## Files + +| File | Side | Purpose | +| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `rpcConcurrencyLogger.ts` | host | The `ProcedureLogger` passed to `openAppWebview`. Delegates to the framework console logger, then feeds each dispatch entry's `concurrent` count into an accumulating-telemetry distribution (`concurrentRpcOps`) so peak / average in-flight RPC concurrency is observable across the fleet (R766-S04). | +| `reportObserverError.ts` | webview | The `ObserverErrorHandler` passed to `WithWebviewContext`'s `onObserverError`. Keeps the framework's structured `console.error` and additionally elevates a throwing RPC event observer to the browser global error stream via `reportError()`, without re-entering the tRPC channel (R766-N05). | +| `rpcConcurrencyLogger.test.ts` | host | Unit tests for the concurrency logger (console delegation + gauge sampling, and that non-dispatch entries are ignored). | +| `reportObserverError.test.ts` | webview | Unit tests for the observer-error sink (structured console line + guarded `reportError()` elevation). | + +## Wiring + +- `rpcConcurrencyLogger` is wired in [`../openAppWebview.ts`](../openAppWebview.ts) + as the `logger` option, so every DocumentDB panel samples RPC concurrency. +- `reportObserverError` is wired in [`../../index.tsx`](../../index.tsx) on + `WithWebviewContext`, so every DocumentDB webview surfaces event-observer + failures instead of only isolating them. diff --git a/src/webviews/_integration/observability/reportObserverError.test.ts b/src/webviews/_integration/observability/reportObserverError.test.ts new file mode 100644 index 000000000..27c322100 --- /dev/null +++ b/src/webviews/_integration/observability/reportObserverError.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { reportObserverError } from './reportObserverError'; + +describe('reportObserverError (R766-N05)', () => { + let consoleErr: jest.SpyInstance; + let originalReportError: unknown; + + beforeEach(() => { + consoleErr = jest.spyOn(console, 'error').mockImplementation(() => {}); + originalReportError = (globalThis as { reportError?: unknown }).reportError; + }); + + afterEach(() => { + consoleErr.mockRestore(); + (globalThis as { reportError?: unknown }).reportError = originalReportError; + }); + + it('logs structured path/phase context and elevates to reportError() when available', () => { + const reportError = jest.fn(); + (globalThis as { reportError?: unknown }).reportError = reportError; + const boom = new Error('observer boom'); + + reportObserverError(boom, { info: { type: 'query', path: 'documents.find' }, phase: 'error' }); + + expect(consoleErr).toHaveBeenCalledWith(expect.stringContaining("'error' of 'documents.find'"), boom); + expect(reportError).toHaveBeenCalledWith(boom); + }); + + it('wraps non-Error values and still logs when reportError is unavailable', () => { + (globalThis as { reportError?: unknown }).reportError = undefined; + + reportObserverError('a string failure', { + info: { type: 'mutation', path: 'documents.save' }, + phase: 'success', + }); + + expect(consoleErr).toHaveBeenCalledTimes(1); + // Non-Error inputs are normalized to an Error before logging. + expect(consoleErr.mock.calls[0][1]).toBeInstanceOf(Error); + expect((consoleErr.mock.calls[0][1] as Error).message).toBe('a string failure'); + }); +}); diff --git a/src/webviews/_integration/observability/reportObserverError.ts b/src/webviews/_integration/observability/reportObserverError.ts new file mode 100644 index 000000000..478f686e2 --- /dev/null +++ b/src/webviews/_integration/observability/reportObserverError.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * R766-N05: DocumentDB's opt-in sink for RPC event-channel observer errors. + * + * The package (`createEventChannel` / `connectTrpc`) always **isolates** a + * throwing `events.onSuccess` / `onError` / `onAborted` handler so it cannot + * break the tRPC call it was only observing, and routes the isolated error to an + * `onObserverError` sink that defaults to `console.error`. The all-in happy path + * deliberately does **not** wire anything beyond that default (it would emit + * events a generic consumer may not want). + * + * DocumentDB opts in for itself: this sink keeps the structured `console.error` + * (path + phase) and additionally elevates the error to the webview's **global + * error stream** via the `reportError()` global. That gives the "general + * observability" of the browser error channel — devtools "Uncaught" plus any + * `window` `error` listener / error-monitoring — while staying safe: it never + * re-enters the tRPC channel, so a throwing observer cannot cause a report loop. + */ + +import { type ObserverErrorHandler } from '@microsoft/vscode-ext-webview/react'; + +/** + * Passed to `WithWebviewContext`'s `onObserverError` so DocumentDB webviews + * surface event-observer failures instead of only isolating them. + */ +export const reportObserverError: ObserverErrorHandler = (error, { info, phase }) => { + const err = error instanceof Error ? error : new Error(String(error)); + + // Structured, always-visible context in the webview devtools console. + console.error(`[DocumentDB] an RPC event observer threw during '${phase}' of '${info.path}'`, err); + + // Elevate to the webview's global error stream via the DOM `reportError()` + // global so global error monitoring picks it up too. Guarded because not + // every runtime exposes it (e.g. tests); the console line above is the floor. + if (typeof globalThis.reportError === 'function') { + globalThis.reportError(err); + } +}; diff --git a/src/webviews/_integration/observability/rpcConcurrencyLogger.test.ts b/src/webviews/_integration/observability/rpcConcurrencyLogger.test.ts new file mode 100644 index 000000000..460f8eb29 --- /dev/null +++ b/src/webviews/_integration/observability/rpcConcurrencyLogger.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ProcedureLogEntry } from '@microsoft/vscode-ext-webview/host'; +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { accumulateTelemetry } from '../../../utils/accumulatingTelemetry'; +import { WEBVIEW_CONFIG } from '../configuration'; +import { rpcConcurrencyLogger } from './rpcConcurrencyLogger'; + +// Mock the two runtime dependencies so the logger can be exercised without +// pulling in the real package (which imports `vscode`) or the telemetry pipeline. +const consoleLog = jest.fn(); +jest.mock('@microsoft/vscode-ext-webview/host', () => ({ + consoleProcedureLogger: { log: (entry: unknown) => consoleLog(entry) }, +})); +jest.mock('../../../utils/accumulatingTelemetry', () => ({ + accumulateTelemetry: jest.fn(), +})); + +/** + * Point `ext.context.extensionMode` at the given mode for a test. The logger + * reads it per call (R766-P05) to decide whether to emit the console line. + */ +function setExtensionMode(mode: vscode.ExtensionMode): void { + // `ext.context` is a partial stub in tests; only `extensionMode` is read here. + (ext as { context: { extensionMode: vscode.ExtensionMode } }).context = { extensionMode: mode }; +} + +describe('rpcConcurrencyLogger (R766-S04)', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default to a non-production mode so the console line is exercised. + setExtensionMode(vscode.ExtensionMode.Development); + }); + + it('delegates to the console logger and records the concurrency gauge + dispatch counter', () => { + const entry: ProcedureLogEntry = { + type: 'query', + path: 'greet', + durationMs: 5, + ok: true, + aborted: false, + concurrent: 3, + }; + + rpcConcurrencyLogger.log(entry); + + expect(consoleLog).toHaveBeenCalledWith(entry); + expect(accumulateTelemetry).toHaveBeenCalledWith( + WEBVIEW_CONFIG.telemetry.rpcConcurrencyEvent, + expect.any(Function), + ); + + // Run the callback against a sample-bag stub to assert what it writes. + const callback = (accumulateTelemetry as jest.Mock).mock.calls[0][1] as (sample: unknown) => void; + const sample = { + properties: {}, + measurements: {} as Record<string, number>, + distributions: {} as Record<string, number>, + }; + callback(sample); + + expect(sample.distributions).toEqual({ concurrentRpcOps: 3 }); + expect(sample.measurements).toEqual({ dispatch: 1 }); + }); + + it('records no telemetry when the entry carries no concurrent count', () => { + const entry: ProcedureLogEntry = { + type: 'mutation', + path: 'save', + durationMs: 2, + ok: true, + aborted: false, + }; + + rpcConcurrencyLogger.log(entry); + + expect(consoleLog).toHaveBeenCalledWith(entry); + expect(accumulateTelemetry).not.toHaveBeenCalled(); + }); + + it('logs the console line in the Test mode too (any non-production mode)', () => { + setExtensionMode(vscode.ExtensionMode.Test); + + rpcConcurrencyLogger.log({ type: 'query', path: 'greet', durationMs: 1, ok: true, aborted: false }); + + expect(consoleLog).toHaveBeenCalledTimes(1); + }); + + it('does NOT emit the console line in Production, but still records the concurrency gauge (R766-P05)', () => { + setExtensionMode(vscode.ExtensionMode.Production); + + const entry: ProcedureLogEntry = { + type: 'query', + path: 'greet', + durationMs: 5, + ok: true, + aborted: false, + concurrent: 7, + }; + + rpcConcurrencyLogger.log(entry); + + // Console line is gated out on a shipped build ... + expect(consoleLog).not.toHaveBeenCalled(); + + // ... but the telemetry gauge is unconditional, so production still samples. + expect(accumulateTelemetry).toHaveBeenCalledWith( + WEBVIEW_CONFIG.telemetry.rpcConcurrencyEvent, + expect.any(Function), + ); + const callback = (accumulateTelemetry as jest.Mock).mock.calls[0][1] as (sample: unknown) => void; + const sample = { + properties: {}, + measurements: {} as Record<string, number>, + distributions: {} as Record<string, number>, + }; + callback(sample); + expect(sample.distributions).toEqual({ concurrentRpcOps: 7 }); + expect(sample.measurements).toEqual({ dispatch: 1 }); + }); +}); diff --git a/src/webviews/_integration/observability/rpcConcurrencyLogger.ts b/src/webviews/_integration/observability/rpcConcurrencyLogger.ts new file mode 100644 index 000000000..c7c090b62 --- /dev/null +++ b/src/webviews/_integration/observability/rpcConcurrencyLogger.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * R766-S04: dispatch logger that records webview RPC **concurrency**. + * + * The framework stamps every dispatch log entry with `concurrent` (the number of + * in-flight operations at completion, including the one being logged). This + * logger keeps the framework's console line for local visibility **in + * non-production modes only** (R766-P05: the per-op `console.log` is dead weight + * on a shipped build where no one is watching the host console) and, in + * addition, feeds `concurrent` into an accumulating-telemetry distribution so we + * can observe peak / average concurrency across the fleet. The telemetry gauge + * runs in every mode, production included. + * + * Why: the transport registers one `window` `message` listener **per in-flight + * operation** (O(N) fan-out per message). That is a sound simplicity trade for + * interactive webviews, but only if `N` stays small. This signal turns the + * "revisit only on evidence" recommendation into data: if `dist_concurrentRpcOps_max` + * stays tiny we can retire the concern; if it is routinely large we build a + * single-listener + `Map<id, observer>` multiplexer. See the R766-S04 discussion + * in the PR-766 review for the exact thresholds. + * + * The event is batched by {@link accumulateTelemetry} (default 20 calls + * / 30 s), so per-RPC volume is negligible. On flush it emits: + * - `dist_concurrentRpcOps_min/max/sum/count` — the concurrency gauge (`max` is + * the peak; `sum / count` is the average); + * - `dispatch` — total operations in the flush window; + * - `dist_auto_duration_ms_*` — per-op latency, recorded for free. + */ + +import { + consoleProcedureLogger, + type ProcedureLogEntry, + type ProcedureLogger, +} from '@microsoft/vscode-ext-webview/host'; +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { accumulateTelemetry } from '../../../utils/accumulatingTelemetry'; +import { WEBVIEW_CONFIG } from '../configuration'; + +/** + * The dispatch logger DocumentDB passes to `openAppWebview`. Delegates to the + * framework's console logger, then records the concurrency gauge sample. + */ +export const rpcConcurrencyLogger: ProcedureLogger = { + log(entry: ProcedureLogEntry): void { + // R766-P05: the framework's console line is a per-op `console.log` on the + // extension host. It is useful while debugging the extension but is dead + // weight (string formatting + I/O) on a shipped, installed build where no + // one is watching that console. Gate it to non-production modes so a + // packaged extension never pays for it, while F5 / dev builds still get + // it. Read the mode per-call: `ext.context` is always populated by the + // time an RPC fires (the webview is open, so the extension has activated), + // and the comparison is O(1). The telemetry gauge below stays + // unconditional so production still records concurrency. + if (ext.context.extensionMode !== vscode.ExtensionMode.Production) { + consoleProcedureLogger.log(entry); + } + + // `concurrent` is only present on entries produced by the transport's + // dispatch pump; guard so a middleware-body entry never mis-samples. + if (typeof entry.concurrent !== 'number') { + return; + } + const concurrent = entry.concurrent; + + accumulateTelemetry(WEBVIEW_CONFIG.telemetry.rpcConcurrencyEvent, (sample) => { + // Gauge: reduced to min / max / sum / count across the batch. + sample.distributions.concurrentRpcOps = concurrent; + // Counter: total operations observed per flush window. + sample.measurements.dispatch = 1; + }); + }, +}; diff --git a/src/webviews/_integration/openAppWebview.ts b/src/webviews/_integration/openAppWebview.ts new file mode 100644 index 000000000..080f0b200 --- /dev/null +++ b/src/webviews/_integration/openAppWebview.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * DocumentDB preset for the framework's `openWebview` factory. + * + * ## Why this helper exists + * + * Opening any DocumentDB webview needs the exact same fixed wiring every time: + * this extension's root tRPC router (`appRouter`), its `createCallerFactory`, + * the bundle / dev-server layout from `WEBVIEW_CONFIG`, and `ext.context`. Only + * a handful of values actually change from one view to the next (the title, the + * `viewType`, the initial config, the router context, and an optional icon or + * view column). + * + * Without this helper every panel factory would repeat that same boilerplate on + * its own `openWebview(...)` call, and any change to the shared wiring (a new + * telemetry logger, a different dev-server host, a renamed bundle file) would + * have to be found and edited in each of them. `openAppWebview` is the single + * place that owns the fixed wiring, so each panel factory stays a few lines long + * and states only what is unique to its view. It is the function-shaped + * replacement for the former `WebviewControllerBase` class: same idea, without + * the inheritance. + * + * ## How it is used + * + * Construction-only panels (no instance state, no externally-called methods + * beyond the handle's `panel` / `onDisposed` / `revealToForeground` / `dispose` + * / `isDisposed`) are opened with a thin factory function (e.g. + * `openCollectionWebview`) that derives config + context and calls this. + */ + +import { openWebview, type WebviewController } from '@microsoft/vscode-ext-webview/host'; +import type * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { appRouter, type AppRouter, type BaseRouterContext } from './appRouter'; +import { WEBVIEW_CONFIG } from './configuration'; +import { rpcConcurrencyLogger } from './observability/rpcConcurrencyLogger'; +import { trpc } from './trpc'; +import { type WebviewName } from './WebviewRegistry'; + +/** + * The DocumentDB-shaped `WebviewController` handle returned by every panel + * factory: the app router and base context are fixed; only the per-view + * configuration varies. + */ +export type AppWebviewController<TConfiguration> = WebviewController<AppRouter, TConfiguration, BaseRouterContext>; + +/** + * Per-view options for {@link openAppWebview}. Everything the framework needs + * that is shared across DocumentDB panels (router, caller factory, layout) is + * supplied by `openAppWebview` itself. + */ +export interface OpenAppWebviewOptions<TConfiguration> { + readonly title: string; + readonly webviewName: WebviewName; + readonly config: TConfiguration; + readonly context: BaseRouterContext; + readonly viewColumn?: vscode.ViewColumn; + readonly icon?: vscode.Uri | { readonly light: vscode.Uri; readonly dark: vscode.Uri }; +} + +/** + * Opens a DocumentDB webview panel through the framework `openWebview` factory. + * + * @param options - The per-view title, webview name, configuration, router + * context, and optional view column / icon. + * @returns The `WebviewController` handle (`panel`, `onDisposed`, + * `revealToForeground`, `dispose`, `isDisposed`). + */ +export function openAppWebview<TConfiguration>( + options: OpenAppWebviewOptions<TConfiguration>, +): AppWebviewController<TConfiguration> { + return openWebview<AppRouter, TConfiguration, BaseRouterContext>(ext.context, { + title: options.title, + viewType: options.webviewName, + router: appRouter, + trpc, + context: options.context, + config: options.config, + sourceLayout: WEBVIEW_CONFIG.bundle, + devServerHost: WEBVIEW_CONFIG.devServerHost, + // `ext.isBundle` (from the webpack `IS_BUNDLE` define) selects the bundle + // vs tsc layout. It is distinct from the extension mode: the dev server + // serves the bundled asset name even in development, so a bundled dev + // build must resolve the `bundled` layout to avoid a 404 on the script. + isBundled: !!ext.isBundle, + // R766-S04: record RPC concurrency (peak / average in-flight operations) + // so the per-operation listener design can be judged on evidence. + logger: rpcConcurrencyLogger, + icon: options.icon, + viewColumn: options.viewColumn, + }); +} diff --git a/src/webviews/_integration/trpc.ts b/src/webviews/_integration/trpc.ts index 0636c0157..600920472 100644 --- a/src/webviews/_integration/trpc.ts +++ b/src/webviews/_integration/trpc.ts @@ -28,100 +28,109 @@ * back from `appRouter.ts`. * * What lives here: - * - `trpcToTelemetry`: middleware that forwards each call to the VS Code - * Azure telemetry pipeline using the `documentDB.rpc.*` event-name - * namespace. - * - `publicProcedureWithTelemetry`: `publicProcedure.use(trpcToTelemetry)`. + * - `documentDbTelemetryRunner`: the `TelemetryRunner` adapter that wraps the + * framework's `telemetryMiddlewareBody`, forwarding each call to the VS Code + * Azure telemetry pipeline using the `documentDB.rpc.*` event-name namespace. + * - `publicProcedureWithTelemetry`: + * `publicProcedure.use((opts) => telemetryMiddlewareBody(opts, documentDbTelemetryRunner))`. * Use this instead of `publicProcedure` when you want the call to be * tracked automatically. * - `WithTelemetry<T>`: re-types the `telemetry` slot on `ctx` to the * richer `ITelemetryContext` so procedure code can access * `suppressAll`, `suppressIfSuccessful`, etc. without ad-hoc casts. - * - Re-exports of `publicProcedure` and `router` so per-view routers - * have a single import location for everything they need. + * - Re-exports of `publicProcedure`, `router`, and `createCallerFactory` so + * per-view routers and the controller share a single import location. */ import { callWithTelemetryAndErrorHandling, parseError, type ITelemetryContext } from '@microsoft/vscode-azext-utils'; +import { initWebviewTrpc, type BaseRouterContext as FrameworkBaseRouterContext } from '@microsoft/vscode-ext-webview'; import { - createMiddleware, - publicProcedure, - router, - type BaseRouterContext as FrameworkBaseRouterContext, -} from '@microsoft/vscode-ext-react-webview/server'; + getInvocationSignal, + telemetryMiddlewareBody, + type WithTelemetry as FrameworkWithTelemetry, + type ProcedureTelemetry, + type TelemetryRunner, +} from '@microsoft/vscode-ext-webview/host'; import { WEBVIEW_CONFIG } from './configuration'; /** - * DocumentDB-flavoured replacement for the package's `WithTelemetry<T>` helper. + * The single tRPC instance for this extension, bound to the framework's + * `BaseRouterContext`. Every per-view router builds its procedures from the + * `publicProcedure` / `router` exported below (procedures cast `ctx` to their + * richer DocumentDB context as needed), and the host dispatcher invokes them + * through this instance's `createCallerFactory`. + */ +const trpc = initWebviewTrpc<FrameworkBaseRouterContext>(); +const { publicProcedure, router, createCallerFactory } = trpc; + +/** + * DocumentDB specialization of the package's generic `WithTelemetry` helper. * - * The package types `telemetry` as its generic `TelemetryContext` - * (`{ properties; measurements }`). In this extension, the runtime value is + * The package types `ctx.telemetry` minimally (`{ properties; measurements }`) + * so it stays telemetry-library-agnostic. In this extension the runtime value is * always the richer `ITelemetryContext` from `@microsoft/vscode-azext-utils` - * (provides `suppressAll`, `suppressIfSuccessful`, etc.). Re-typing the helper - * here lets procedure code access those fields without ad-hoc casts. + * (`suppressAll`, `suppressIfSuccessful`, etc.). This one-line alias binds the + * package helper's telemetry type to `ITelemetryContext`, so procedure code can + * annotate `ctx` as `WithTelemetry<SomeContext>` and read those fields without + * ad-hoc casts. */ -export type WithTelemetry<T extends { telemetry?: unknown }> = Omit<T, 'telemetry'> & { - telemetry: ITelemetryContext; -}; +export type WithTelemetry<T extends { telemetry?: unknown }> = FrameworkWithTelemetry<T, ITelemetryContext>; /** - * Telemetry middleware that forwards every tRPC call to the VS Code Azure - * telemetry pipeline. Event names follow the `documentDB.rpc.${type}.${path}` - * convention. + * DocumentDB telemetry adapter for the framework's `telemetryMiddlewareBody`. + * + * The body owns the generic timing / `Canceled` / `Failed` / error-name + + * error-message recording; this runner establishes the VS Code Azure telemetry + * scope (event names follow the `documentDB.rpc.${type}.${path}` convention) and + * adds the DocumentDB-specific error enrichment the body does not: `parseError` + * is used so non-enumerable Error fields are read correctly and the telemetry + * path never throws (e.g. on circular `cause` chains), and `errorStack` / + * `errorCause` are recorded. The enrichment runs after `execute`, so its + * `parseError`-derived `error` / `errorMessage` values overwrite the body's + * plain name / message — except for aborted calls, which are left as the body's + * `Canceled` classification with no error fields (R766-C02, matching R766-C01). */ -const trpcToTelemetry = createMiddleware(async (opts) => { - const result = await callWithTelemetryAndErrorHandling( - `${WEBVIEW_CONFIG.telemetry.rpcEventPrefix}.${opts.type}.${opts.path}`, - async (context) => { - context.errorHandling.suppressDisplay = true; +const documentDbTelemetryRunner: TelemetryRunner = { + async run(invocation, execute) { + const result = await callWithTelemetryAndErrorHandling( + `${WEBVIEW_CONFIG.telemetry.rpcEventPrefix}.${invocation.type}.${invocation.path}`, + async (context) => { + context.errorHandling.suppressDisplay = true; - const result = await opts.next({ - ctx: { - ...opts.ctx, - telemetry: context.telemetry, - }, - }); + // `ITelemetryContext` is the runtime telemetry object; its + // `properties` / `measurements` index signatures are wider than + // the framework's `ProcedureTelemetry` (they also admit + // `undefined` / `TelemetryTrustedValue`). The framework body only + // ever writes plain strings / numbers, so the bridge is sound. + const result = await execute(context.telemetry as unknown as ProcedureTelemetry); - // Check if the operation was aborted via AbortSignal - const signal = (opts.ctx as FrameworkBaseRouterContext).signal; - if (signal?.aborted) { - context.telemetry.properties.aborted = 'true'; - context.telemetry.properties.result = 'Canceled'; - } - - if (!result.ok) { - /** - * we're not handling any error here as we just want to log it here and let the - * caller of the RPC call handle the error there. - */ - - if (!signal?.aborted) { - context.telemetry.properties.result = 'Failed'; + // Skip the DocumentDB error enrichment for an aborted call. The + // framework body already recorded it as 'Canceled' without error + // details (R766-C01); re-stamping `error*` here would undo that + // and make a cancellation look like a failure (R766-C02). + const aborted = getInvocationSignal(invocation.ctx)?.aborted ?? false; + if (!result.ok && result.error && !aborted) { + const parsed = parseError(result.error); + context.telemetry.properties.error = parsed.errorType; + context.telemetry.properties.errorMessage = parsed.message; + context.telemetry.properties.errorStack = (result.error as { stack?: string }).stack ?? ''; + if (result.error.cause) { + context.telemetry.properties.errorCause = parseError(result.error.cause).message; + } } - // Use the same `parseError` helper the rest of the codebase uses so that - // non-enumerable Error fields (message/name/stack) are read correctly and - // we never throw from the telemetry path (e.g. on circular `cause` chains, - // which JSON.stringify would have thrown on). - const parsed = parseError(result.error); - context.telemetry.properties.error = parsed.errorType; - context.telemetry.properties.errorMessage = parsed.message; - context.telemetry.properties.errorStack = result.error.stack ?? ''; - if (result.error.cause) { - context.telemetry.properties.errorCause = parseError(result.error.cause).message; - } - } + return result; + }, + ); - return result; - }, - ); + if (!result) { + // This should never happen, but TypeScript requires us to handle the case where result is undefined. + throw new Error(`No result returned from tRPC call for ${invocation.type} ${invocation.path}`); + } - if (!result) { - // This should never happen, but TypeScript requires us to handle the case where result is undefined. - throw new Error(`No result returned from tRPC call for ${opts.type} ${opts.path}`); - } - - return result; -}); + return result; + }, +}; /** * Base procedure that automatically attaches DocumentDB Azure telemetry context. @@ -130,9 +139,14 @@ const trpcToTelemetry = createMiddleware(async (opts) => { * tracked. The `telemetry` object is available on `ctx` inside your procedure * handlers (cast with `WithTelemetry<YourContext>`). */ -export const publicProcedureWithTelemetry = publicProcedure.use(trpcToTelemetry); +export const publicProcedureWithTelemetry = publicProcedure.use((opts) => + telemetryMiddlewareBody(opts, documentDbTelemetryRunner), +); -// Re-export the unprotected procedure builder and router factory so per-view -// routers have a single import location for everything they need to declare -// tRPC procedures. -export { publicProcedure, router }; +// Re-export the tRPC instance, the unprotected procedure builder, the router +// factory, and the caller factory so per-view routers have a single import +// location for everything they need, and the controller can invoke procedures +// against the same tRPC instance the router was built with. Panel factories pass +// `trpc` to `openAppWebview` (R766-N02) so the caller factory rides along with +// the instance and cannot be mismatched with the router. +export { createCallerFactory, publicProcedure, router, trpc }; diff --git a/src/webviews/_integration/useTrpcClient.ts b/src/webviews/_integration/useTrpcClient.ts index 2c2ed753b..e35fcf669 100644 --- a/src/webviews/_integration/useTrpcClient.ts +++ b/src/webviews/_integration/useTrpcClient.ts @@ -5,14 +5,17 @@ /** * Thin wrapper around the framework's `useTrpcClient` hook from - * `@microsoft/vscode-ext-react-webview`, pre-typed against this extension's + * `@microsoft/vscode-ext-webview/react`, pre-typed against this extension's * {@link AppRouter}. Webview components import from here so they do not need * to repeat the router type argument at every call site. + * + * The framework hook returns the tRPC client directly (client-first shape), so + * this wrapper does too; call it as `const trpcClient = useTrpcClient();`. */ -import { useTrpcClient as useFrameworkTrpcClient } from '@microsoft/vscode-ext-react-webview'; +import { useTrpcClient as useFrameworkTrpcClient, type TrpcClient } from '@microsoft/vscode-ext-webview/react'; import { type AppRouter } from './appRouter'; -export function useTrpcClient() { +export function useTrpcClient(): TrpcClient<AppRouter> { return useFrameworkTrpcClient<AppRouter>(); } diff --git a/src/webviews/documentdb/collectionView/CollectionView.tsx b/src/webviews/documentdb/collectionView/CollectionView.tsx index 4e335809f..7e0ef7a0a 100644 --- a/src/webviews/documentdb/collectionView/CollectionView.tsx +++ b/src/webviews/documentdb/collectionView/CollectionView.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Badge, ProgressBar, Tab, TabList } from '@fluentui/react-components'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; import { type JSX, useEffect, useRef, useState } from 'react'; import { type TableDataEntry } from '../../../documentdb/ClusterSession'; @@ -57,7 +57,7 @@ export const CollectionView = (): JSX.Element => { /** * Use the `useTrpcClient` hook to get the tRPC client */ - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); /** * Please note: using the context and states inside of closures can lead to stale data. diff --git a/src/webviews/documentdb/collectionView/collectionViewController.ts b/src/webviews/documentdb/collectionView/collectionViewController.ts index 50732af0f..dea90bb5e 100644 --- a/src/webviews/documentdb/collectionView/collectionViewController.ts +++ b/src/webviews/documentdb/collectionView/collectionViewController.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import { API } from '../../../DocumentDBExperiences'; import { ext } from '../../../extensionVariables'; import { SettingsService } from '../../../services/SettingsService'; -import { WebviewControllerBase } from '../../_integration/WebviewControllerBase'; +import { type AppWebviewController, openAppWebview } from '../../_integration/openAppWebview'; import { type RouterContext } from './collectionViewRouter'; export type CollectionViewWebviewConfigurationType = { @@ -39,45 +39,44 @@ export type CollectionViewWebviewConfigurationType = { }; }; -export class CollectionViewController extends WebviewControllerBase<CollectionViewWebviewConfigurationType> { - constructor( - initialData: Omit<CollectionViewWebviewConfigurationType, 'defaultPageSize' | 'enableAIQueryGeneration'>, - ) { - // ext.context here is the vscode.ExtensionContext required by the ReactWebviewPanelController's original implementation - // we're not modifying it here in order to be ready for future updates of the webview API. +export function openCollectionWebview( + initialData: Omit<CollectionViewWebviewConfigurationType, 'defaultPageSize' | 'enableAIQueryGeneration'>, +): AppWebviewController<CollectionViewWebviewConfigurationType> { + const title: string = `${initialData.databaseName}/${initialData.collectionName}`; - const title: string = `${initialData.databaseName}/${initialData.collectionName}`; + // Get the default page size from settings + const defaultPageSize = SettingsService.getSetting<number>(ext.settingsKeys.collectionViewDefaultPageSize) ?? 50; - // Get the default page size from settings - const defaultPageSize = - SettingsService.getSetting<number>(ext.settingsKeys.collectionViewDefaultPageSize) ?? 50; + // Get the experimental AI query generation setting + const enableAIQueryGeneration = + SettingsService.getSetting<boolean>(ext.settingsKeys.enableAIQueryGeneration) ?? false; - // Get the experimental AI query generation setting - const enableAIQueryGeneration = - SettingsService.getSetting<boolean>(ext.settingsKeys.enableAIQueryGeneration) ?? false; + const fullInitialData: CollectionViewWebviewConfigurationType = { + ...initialData, + defaultPageSize, + enableAIQueryGeneration, + }; - const fullInitialData: CollectionViewWebviewConfigurationType = { - ...initialData, - defaultPageSize, - enableAIQueryGeneration, - }; + const trpcContext: RouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'collectionView', + sessionId: initialData.sessionId, + clusterId: initialData.clusterId, + clusterDisplayName: initialData.clusterDisplayName, + viewId: initialData.viewId, + databaseName: initialData.databaseName, + collectionName: initialData.collectionName, + }; - super(ext.context, title, 'collectionView', fullInitialData, vscode.ViewColumn.One, { + return openAppWebview({ + title, + webviewName: 'collectionView', + config: fullInitialData, + context: trpcContext, + viewColumn: vscode.ViewColumn.One, + icon: { light: vscode.Uri.joinPath(ext.context.extensionUri, 'resources', 'icons', 'collection-view-light.svg'), dark: vscode.Uri.joinPath(ext.context.extensionUri, 'resources', 'icons', 'collection-view-dark.svg'), - }); - - const trpcContext: RouterContext = { - dbExperience: API.DocumentDB, - webviewName: 'collectionView', - sessionId: initialData.sessionId, - clusterId: initialData.clusterId, - clusterDisplayName: initialData.clusterDisplayName, - viewId: initialData.viewId, - databaseName: initialData.databaseName, - collectionName: initialData.collectionName, - }; - - this.setupTrpc(trpcContext); - } + }, + }); } diff --git a/src/webviews/documentdb/collectionView/collectionViewRouter.ts b/src/webviews/documentdb/collectionView/collectionViewRouter.ts index 26773eef4..e15d0ddfe 100644 --- a/src/webviews/documentdb/collectionView/collectionViewRouter.ts +++ b/src/webviews/documentdb/collectionView/collectionViewRouter.ts @@ -25,7 +25,7 @@ import { Views } from '../../../documentdb/Views'; import { ext } from '../../../extensionVariables'; import { COMPLETION_CATEGORIES, CompletionSources } from '../../../telemetry/completionCategories'; import { type CollectionItem } from '../../../tree/documentdb/CollectionItem'; -import { callWithAccumulatingTelemetry } from '../../../utils/callWithAccumulatingTelemetry'; +import { accumulateTelemetry } from '../../../utils/accumulatingTelemetry'; import { escapeJsString } from '../../../utils/escapeJsString'; import { toFieldCompletionItems } from '../../../utils/json/data-api/autocomplete/toFieldCompletionItems'; import { promptAfterActionEventually } from '../../../utils/survey'; @@ -688,8 +688,8 @@ export const collectionsViewRouter = router({ `Unknown completion category received (source: ${CompletionSources.CollectionView})`, ); } - void callWithAccumulatingTelemetry('completion.accepted.cv', (accCtx) => { - accCtx.telemetry.measurements[`cat_${input.category}_src_${CompletionSources.CollectionView}`] = 1; + accumulateTelemetry('completion.accepted.cv', (sample) => { + sample.measurements[`cat_${input.category}_src_${CompletionSources.CollectionView}`] = 1; }); }), }); diff --git a/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx b/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx index 2e7f59043..488b0d56b 100644 --- a/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx +++ b/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx @@ -5,7 +5,7 @@ import { Button, Input, Label, ToggleButton, Tooltip } from '@fluentui/react-components'; import { Collapse } from '@fluentui/react-motion-components-preview'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; // eslint-disable-line import/no-internal-modules import { useContext, useEffect, useRef, useState, type JSX } from 'react'; @@ -61,7 +61,7 @@ interface QueryEditorProps { } export const QueryEditor = ({ onExecuteRequest }: QueryEditorProps): JSX.Element => { - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const configuration = useConfiguration<CollectionViewWebviewConfigurationType>(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); const [isEnhancedQueryMode, setIsEnhancedQueryMode] = useState(false); diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx index 2aa114356..76a386e86 100644 --- a/src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx @@ -33,7 +33,7 @@ import { Link, MessageBar, MessageBarBody, Skeleton, SkeletonItem, Text, tokens } from '@fluentui/react-components'; import { ChatMailRegular, InfoRegular, SparkleRegular, WarningRegular } from '@fluentui/react-icons'; import { CollapseRelaxed, Fade } from '@fluentui/react-motion-components-preview'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; import { useTrpcClient } from '../../../../_integration/useTrpcClient'; @@ -90,7 +90,7 @@ export const QueryInsightsMain = (): JSX.Element => { */ const configuration = useConfiguration<CollectionViewWebviewConfigurationType>(); - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); const pipeline = currentContext.queryInsights; @@ -161,7 +161,7 @@ export const QueryInsightsMain = (): JSX.Element => { * sends `subscription.stop` to the host, which both aborts the * per-operation `AbortController` and calls `iterator.return()` on the * procedure's async generator — so the underlying LLM call is cancelled - * in lock step. See packages/vscode-ext-react-webview README. + * in lock step. See packages/vscode-ext-webview ADVANCED.md. */ const stage3SubscriptionRef = useRef<{ unsubscribe: () => void } | null>(null); diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx index f56d5e3c8..78907cda7 100644 --- a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx @@ -43,7 +43,7 @@ export const QueryPlanSummary: React.FC<QueryPlanSummaryProps> = ({ stage2Loading, hasError, }) => { - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const handleStageDetailsToggle = (isExpanded: boolean) => { trpcClient.common.reportEvent diff --git a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx index cb308c7c2..ac36f88aa 100644 --- a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx +++ b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx @@ -34,7 +34,7 @@ import { SparkleRegular, WindowConsoleRegular, } from '@fluentui/react-icons'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; import { useContext, type JSX } from 'react'; import { useTrpcClient } from '../../../../_integration/useTrpcClient'; @@ -56,7 +56,7 @@ const ToolbarQueryOperations = (): JSX.Element => { /** * Use the `useTrpcClient` hook to get the tRPC client */ - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const configuration = useConfiguration<CollectionViewWebviewConfigurationType>(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); @@ -205,7 +205,7 @@ const ToolbarQueryOperations = (): JSX.Element => { */ const ToolbarSecondaryActions = (): JSX.Element => { const [currentContext, setCurrentContext] = useContext(CollectionViewContext); - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); // ─── Handlers ─────────────────────────────────────────────────────────────── diff --git a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarTableNavigation.tsx b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarTableNavigation.tsx index 11aca3562..d3cff776f 100644 --- a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarTableNavigation.tsx +++ b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarTableNavigation.tsx @@ -25,7 +25,7 @@ export const ToolbarTableNavigation = (): React.JSX.Element => { /** * Use the `useTrpcClient` hook to get the tRPC client */ - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); diff --git a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx index 0e489d8d3..6c3477a9b 100644 --- a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx +++ b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx @@ -16,7 +16,7 @@ export const ToolbarViewNavigation = (): React.JSX.Element => { /** * Use the `useTrpcClient` hook to get the tRPC client */ - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); diff --git a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts index f19740757..a21c2d100 100644 --- a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts +++ b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts @@ -129,7 +129,7 @@ export const queryInsightsRouter = router({ const session: ClusterSession = ClusterSession.getSession(sessionId); const clusterMetadata = await session.getClient().getClusterMetadata(); - ctx.telemetry.properties.platform = clusterMetadata?.domainInfo_api ?? 'unknown'; + myCtx.telemetry.properties.platform = clusterMetadata?.domainInfo_api ?? 'unknown'; if (clusterMetadata?.domainInfo_api === 'RU') { // TODO: Platform identification improvements needed // 1. Create a centralized platform detection service (ClusterSession.getPlatformType()) @@ -246,7 +246,7 @@ export const queryInsightsRouter = router({ const session: ClusterSession = ClusterSession.getSession(sessionId); const clusterMetadata = await session.getClient().getClusterMetadata(); - ctx.telemetry.properties.platform = clusterMetadata?.domainInfo_api ?? 'unknown'; + myCtx.telemetry.properties.platform = clusterMetadata?.domainInfo_api ?? 'unknown'; // Get query parameters from session with parsed BSON objects const queryParams = session.getCurrentFindQueryParamsWithObjects(); @@ -328,37 +328,37 @@ export const queryInsightsRouter = router({ // --- Stage 2 telemetry --- // Performance metrics (safe to aggregate, no PII/OII) - ctx.telemetry.properties.performanceScore = transformed.efficiencyAnalysis.performanceRating.score; - ctx.telemetry.properties.executionStrategy = transformed.executionStrategy; - ctx.telemetry.properties.indexUsed = transformed.indexUsed ? 'true' : 'false'; - ctx.telemetry.properties.hadCollectionScan = transformed.hadCollectionScan ? 'true' : 'false'; - ctx.telemetry.properties.hadInMemorySort = transformed.hadInMemorySort ? 'true' : 'false'; - ctx.telemetry.properties.isCoveringQuery = transformed.isCoveringQuery ? 'true' : 'false'; - ctx.telemetry.properties.fetchOverheadKind = transformed.efficiencyAnalysis.fetchOverheadKind; - ctx.telemetry.properties.isSharded = transformed.isSharded ? 'true' : 'false'; - - ctx.telemetry.measurements.executionTimeMs = transformed.executionTimeMs; - ctx.telemetry.measurements.documentsReturned = transformed.documentsReturned; - ctx.telemetry.measurements.totalDocsExamined = transformed.totalDocsExamined; - ctx.telemetry.measurements.totalKeysExamined = transformed.totalKeysExamined; - ctx.telemetry.measurements.examinedToReturnedRatio = transformed.examinedToReturnedRatio; - ctx.telemetry.measurements.diagnosticBadgeCount = + myCtx.telemetry.properties.performanceScore = transformed.efficiencyAnalysis.performanceRating.score; + myCtx.telemetry.properties.executionStrategy = transformed.executionStrategy; + myCtx.telemetry.properties.indexUsed = transformed.indexUsed ? 'true' : 'false'; + myCtx.telemetry.properties.hadCollectionScan = transformed.hadCollectionScan ? 'true' : 'false'; + myCtx.telemetry.properties.hadInMemorySort = transformed.hadInMemorySort ? 'true' : 'false'; + myCtx.telemetry.properties.isCoveringQuery = transformed.isCoveringQuery ? 'true' : 'false'; + myCtx.telemetry.properties.fetchOverheadKind = transformed.efficiencyAnalysis.fetchOverheadKind; + myCtx.telemetry.properties.isSharded = transformed.isSharded ? 'true' : 'false'; + + myCtx.telemetry.measurements.executionTimeMs = transformed.executionTimeMs; + myCtx.telemetry.measurements.documentsReturned = transformed.documentsReturned; + myCtx.telemetry.measurements.totalDocsExamined = transformed.totalDocsExamined; + myCtx.telemetry.measurements.totalKeysExamined = transformed.totalKeysExamined; + myCtx.telemetry.measurements.examinedToReturnedRatio = transformed.examinedToReturnedRatio; + myCtx.telemetry.measurements.diagnosticBadgeCount = transformed.efficiencyAnalysis.performanceRating.diagnostics.length; if (totalCollectionDocs !== undefined) { - ctx.telemetry.measurements.totalCollectionDocs = totalCollectionDocs; + myCtx.telemetry.measurements.totalCollectionDocs = totalCollectionDocs; } const selectivityPercent = transformed.efficiencyAnalysis.selectivity; if (selectivityPercent !== null && selectivityPercent !== undefined) { - ctx.telemetry.measurements.selectivityPercent = selectivityPercent; + myCtx.telemetry.measurements.selectivityPercent = selectivityPercent; } // Badge IDs (safe categorical data, no PII) const diagnosticIds = transformed.efficiencyAnalysis.performanceRating.diagnostics .map((d) => d.diagnosticId) .join(','); - ctx.telemetry.properties.diagnosticBadgeIds = diagnosticIds; + myCtx.telemetry.properties.diagnosticBadgeIds = diagnosticIds; // Count badges by type const badgesByType = transformed.efficiencyAnalysis.performanceRating.diagnostics.reduce( @@ -368,9 +368,9 @@ export const queryInsightsRouter = router({ }, {} as Record<string, number>, ); - ctx.telemetry.measurements.positiveBadgeCount = badgesByType['positive'] ?? 0; - ctx.telemetry.measurements.neutralBadgeCount = badgesByType['neutral'] ?? 0; - ctx.telemetry.measurements.negativeBadgeCount = badgesByType['negative'] ?? 0; + myCtx.telemetry.measurements.positiveBadgeCount = badgesByType['positive'] ?? 0; + myCtx.telemetry.measurements.neutralBadgeCount = badgesByType['neutral'] ?? 0; + myCtx.telemetry.measurements.negativeBadgeCount = badgesByType['negative'] ?? 0; ext.outputChannel.trace( l10n.t( diff --git a/src/webviews/documentdb/documentView/documentView.tsx b/src/webviews/documentdb/documentView/documentView.tsx index 6ebba72f1..ce54f6c51 100644 --- a/src/webviews/documentdb/documentView/documentView.tsx +++ b/src/webviews/documentdb/documentView/documentView.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ProgressBar } from '@fluentui/react-components'; -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import { loader } from '@monaco-editor/react'; import * as l10n from '@vscode/l10n'; import { debounce } from 'es-toolkit'; @@ -50,7 +50,7 @@ export const DocumentView = (): JSX.Element => { /** * Use the `useTrpcClient` hook to get the tRPC client */ - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const initialContent = configuration.mode === 'add' ? '{ }' : '{ "loading…": true }'; const [editorContent] = useState(initialContent); diff --git a/src/webviews/documentdb/documentView/documentsViewController.ts b/src/webviews/documentdb/documentView/documentsViewController.ts index 5c7faf0cd..72205ac1a 100644 --- a/src/webviews/documentdb/documentView/documentsViewController.ts +++ b/src/webviews/documentdb/documentView/documentsViewController.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../../DocumentDBExperiences'; import { ext } from '../../../extensionVariables'; -import { WebviewControllerBase } from '../../_integration/WebviewControllerBase'; +import { type AppWebviewController, openAppWebview } from '../../_integration/openAppWebview'; import { type RouterContext } from './documentsViewRouter'; export type DocumentsViewWebviewConfigurationType = { @@ -26,38 +26,54 @@ export type DocumentsViewWebviewConfigurationType = { mode: string; // 'add', 'view', 'edit' }; -export class DocumentsViewController extends WebviewControllerBase<DocumentsViewWebviewConfigurationType> { - constructor(initialData: DocumentsViewWebviewConfigurationType) { - // ext.context here is the vscode.ExtensionContext required by the ReactWebviewPanelController's original implementation - // we're not modifying it here in order to be ready for future updates of the webview API. - - let title: string = `${initialData.databaseName}/${initialData.collectionName}/*new*`; - switch (initialData.mode) { - case 'view': - case 'edit': { - title = `${initialData.databaseName}/${initialData.collectionName}/${initialData.documentId}`; - break; - } +export function openDocumentWebview( + initialData: DocumentsViewWebviewConfigurationType, +): AppWebviewController<DocumentsViewWebviewConfigurationType> { + let title: string = `${initialData.databaseName}/${initialData.collectionName}/*new*`; + switch (initialData.mode) { + case 'view': + case 'edit': { + title = `${initialData.databaseName}/${initialData.collectionName}/${initialData.documentId}`; + break; } + } + + // The router context's title setter needs the controller handle, which only + // exists after openAppWebview returns. The setter is only invoked at runtime + // (in response to a tRPC call), well after the handle is assigned below. + const handle: { controller?: AppWebviewController<DocumentsViewWebviewConfigurationType> } = {}; + + const trpcContext: RouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'documentView', + clusterId: initialData.clusterId, + viewId: initialData.viewId, + databaseName: initialData.databaseName, + collectionName: initialData.collectionName, + documentId: initialData.documentId, + viewPanelTitleSetter: (title: string) => { + if (handle.controller) { + handle.controller.panel.title = title; + } + }, + }; - super(ext.context, title, 'documentView', initialData, vscode.ViewColumn.Active, { + const controller = openAppWebview({ + title, + webviewName: 'documentView', + config: initialData, + context: trpcContext, + viewColumn: vscode.ViewColumn.Active, + icon: { light: vscode.Uri.joinPath(ext.context.extensionUri, 'resources', 'icons', 'document-view-light.svg'), dark: vscode.Uri.joinPath(ext.context.extensionUri, 'resources', 'icons', 'document-view-dark.svg'), - }); - - const trpcContext: RouterContext = { - dbExperience: API.DocumentDB, - webviewName: 'documentView', - clusterId: initialData.clusterId, - viewId: initialData.viewId, - databaseName: initialData.databaseName, - collectionName: initialData.collectionName, - documentId: initialData.documentId, - viewPanelTitleSetter: (title: string) => { - this.panel.title = title; - }, - }; - - this.setupTrpc(trpcContext); - } + }, + }); + + // Assign into the deferred handle so the title-setter closure above can reach + // the controller, then return the local `const` (never the optional + // `handle.controller`), so the return path is obviously non-nullable. + handle.controller = controller; + + return controller; } diff --git a/src/webviews/index.tsx b/src/webviews/index.tsx index bfffbe6bc..ea0e370f9 100644 --- a/src/webviews/index.tsx +++ b/src/webviews/index.tsx @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type WebviewState, WithWebviewContext } from '@microsoft/vscode-ext-react-webview'; +import { type WebviewState, WithWebviewContext } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; import { type l10nJsonFormat } from '@vscode/l10n'; import type * as React from 'react'; import { createRoot } from 'react-dom/client'; // eslint-disable-line import/no-internal-modules import { type WebviewApi } from 'vscode-webview'; +import { reportObserverError } from './_integration/observability/reportObserverError'; import { type WebviewName, WebviewRegistry } from './_integration/WebviewRegistry'; import { DynamicThemeProvider } from './theme/DynamicThemeProvider'; @@ -29,7 +30,7 @@ export function render<V extends ViewKey>(key: V, vscodeApi: WebviewApi<WebviewS root.render( <DynamicThemeProvider useAdaptive={true}> - <WithWebviewContext vscodeApi={vscodeApi}> + <WithWebviewContext vscodeApi={vscodeApi} onObserverError={reportObserverError}> <Component /> </WithWebviewContext> </DynamicThemeProvider>, diff --git a/tsconfig.json b/tsconfig.json index e85ca7d0c..16ecca647 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,6 @@ { "path": "packages/documentdb-js-schema-analyzer" }, { "path": "packages/documentdb-js-operator-registry" }, { "path": "packages/documentdb-js-shell-runtime" }, - { "path": "packages/vscode-ext-react-webview" } + { "path": "packages/vscode-ext-webview" } ] }