Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .github/skills/vscode-messenger/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class ColorsViewProvider implements vscode.WebviewViewProvider {

- `new Messenger(options?)` — `MessengerOptions`: `ignoreHiddenViews` (default `true`), `uniqueHandlers` (throws if a handler for the same method is registered twice — incompatible with sender-scoped handlers; see that section below), `debugLog`.
- `registerWebviewView(view, options?)` / `registerWebviewPanel(panel, options?)` — returns a `WebviewIdMessageParticipant` with the assigned `webviewId`. Use this returned participant to address that **specific instance** (vs. the `webviewType` string which addresses **all instances** of that type). The library auto-unregisters on `onDidDispose`.
- `onRequest(type, handler, { sender? })` / `onNotification(type, handler, { sender? })` — return a `Disposable`. **Extension side:** for **notifications**, multiple handlers for the same method stack and all fire. For **requests**, registering more than one handler for the same method is invalidit causes a runtime error response ("Multiple matching request handlers") when that request arrives; use `sender`-scoped handlers instead if you need per-webview logic for the same method (see below). **Webview side:** registering a new handler for the same method **replaces** the previous one (last-write-wins). The optional `sender` (extension side only) filters the handler so it only fires for messages from that participant.
- `onRequest(type, handler, { sender? })` / `onNotification(type, handler, { sender? })` — return a `Disposable`. **Both sides:** for **notifications**, multiple handlers for the same method stack and all fire. For **requests**, only one handler per method is alloweda second registration for an overlapping scope **throws synchronously** at registration time (webview: any second request handler for the method; extension: a second handler whose `sender` scope overlaps). On the extension side, use non-overlapping `sender`-scoped handlers if you need per-webview request logic for the same method (see below). Disposing a handler removes only that specific registration. The optional `sender` (extension side only) filters the handler so it only fires for messages from that participant.
- `sendRequest(type, receiver, params?, cancelable?)` — returns `Promise<R>`. Receiver is a `MessageParticipant` (webview by id, webview by type, or — once supported — another extension). Cannot be `BROADCAST` — throws immediately on both sides.
- `sendNotification(type, receiver, params?)` — fire-and-forget. Receiver may be `BROADCAST`.

Expand Down Expand Up @@ -121,6 +121,48 @@ const vscodeApi = acquireVsCodeApi();
const messenger = new Messenger(vscodeApi);
```

## React webviews (Vite + StrictMode)

When the webview UI is a React app, create the `Messenger` **once** outside the component tree, and register handlers inside a `useEffect` that disposes them on cleanup. This is required because React StrictMode (dev) mounts every component twice (`setup → cleanup → setup`), and Vite HMR re-runs effects on every edit. Registering a **request** handler without disposing the previous one throws on the second run (`A request handler is already registered for method ...`); a **notification** handler without cleanup fires twice.

```tsx
// messenger.ts — module scope, created exactly once per webview
import { Messenger } from 'vscode-messenger-webview';
export const messenger = new Messenger(); // acquireVsCodeApi() called once here

// App.tsx
import { useEffect, useState } from 'react';
import { HOST_EXTENSION } from 'vscode-messenger-common';
import { messenger } from './messenger';
import { ColorModify, GetColors } from './shared/message-types';

export function App() {
const [colors, setColors] = useState<string[]>([]);

useEffect(() => {
// Collect every registration and dispose it on cleanup.
const disposables = [
messenger.onNotification(ColorModify, action => {
if (action === 'clear') setColors([]);
}),
messenger.onRequest(GetColors, () => colors),
];
messenger.start(); // idempotent — safe to call again after a StrictMode remount

return () => disposables.forEach(d => d.dispose());
}, []); // run once on mount; StrictMode runs setup→cleanup→setup, which stays balanced

return null;
}
```

Key rules for React:

- **One `Messenger` per webview, at module scope** — never `new Messenger()` inside a component body (`acquireVsCodeApi()` may be called only once, and a new instance per render leaks listeners).
- **Register in `useEffect`, dispose in its cleanup** — the returned cleanup disposes each `Disposable`, so the StrictMode double-invoke and HMR re-runs stay balanced.
- **`start()` is idempotent** — calling it again after a remount is a no-op (`if (this.started) return`).
- **Avoid registering in the component body or in a `useMemo`** — those run on every render and will throw on the second request-handler registration.

## Core patterns

### Request / response
Expand Down Expand Up @@ -210,7 +252,7 @@ If you address by `webviewType` instead and multiple instances of that type are

### Runtime and targeting pitfalls

- **Webview handlers are last-write-wins** — on the webview side, `onRequest`/`onNotification` for the same method **replace** the previous handler. On the extension side, notification handlers stack and all fire, but registering more than one **request** handler for the same method is invalid usage: it results in a runtime error response ("Multiple matching request handlers") rather than being silently accepted. Use `uniqueHandlers: true` to catch accidental duplicate registrations at registration time (throws immediately). If you re-register on the webview during HMR or re-mount, dispose the old `Disposable` first.
- **Duplicate request handlers throw at registration** — registering a second **request** handler for the same method throws synchronously (webview: any duplicate; extension: when the `sender` scopes overlap). This surfaces accidental double-registration immediately instead of failing later at dispatch. **Notification** handlers, by contrast, stack: registering the same method twice means the handler fires twice. Always keep the returned `Disposable` and call `dispose()` before re-registering (during HMR, re-mount, or React StrictMode) — see the React section below. The extension-side `uniqueHandlers: true` option additionally forbids **all** duplicate method registrations (requests and notifications), which is incompatible with sender-scoped handlers.
- **`webviewType` with multiple instances races requests** — if you really want to broadcast a question and aggregate, you have to do it yourself (iterate instances by id and `Promise.all`). The library only returns the first response.
- **`extensionId` is reserved for future use** — `ExtensionMessageParticipant.extensionId` is in the type but cross-extension messaging isn't implemented. `sendRequest` to `{ type: 'extension', extensionId }` throws. Use `HOST_EXTENSION` (no `extensionId`) for the host extension.
- **`webviewId` changes on every register** — the id is generated fresh each time `registerWebviewView` / `registerWebviewPanel` is called. Don't persist it across sessions; capture the returned participant and use it for the lifetime of that view.
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Typed RPC library for message-passing between a VS Code extension host and its w
```sh
npm install # ~2s warm
npm run build # tsc -b (typecheck+compile all packages) + browserify bundles + devtools-ui vite build + lint, ~8.5s
npm test # jest: vscode-messenger + vscode-messenger-webview, 44 tests, ~2.4s
npm test # jest: vscode-messenger + vscode-messenger-webview, 53 tests, ~2.4s
npx jest packages/vscode-messenger/tests/messenger.test.ts # single test file
npm run lint # eslint --workspaces, ~3.7s
npm run watch # tsc -b -w, incremental rebuild across all packages
Expand All @@ -28,12 +28,16 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full module index.
## Conventions

- Keep all `NotificationType`/`RequestType` declarations in one shared module imported by both the extension and webview side — the same `method` string and generic types must match on both ends, and nothing enforces this beyond a shared import.
- A `method` string must be used exclusively for requests **or** for notifications — never both. `onRequest` and `onNotification` throw synchronously at registration time if this rule or a conflicting sender scope is violated; the error message names the method and the conflicting handler kind.
- On the extension-host side, multiple `onRequest` handlers for the same method are allowed only when each uses a distinct, non-overlapping `sender` scope (enforced at registration). Multiple `onNotification` handlers always stack and all fire in parallel.
- On the webview side, at most one `onRequest` handler per method; multiple `onNotification` handlers stack. Always keep the returned `Disposable` and call `dispose()` before re-registering (required in React `useEffect` — see README.md for the pattern, especially under StrictMode).
- ESLint already enforces most style (single quotes, semicolons, `no-explicit-any`, `no-var`, arrow/paren spacing — see `eslint.config.js`); don't restate those as review comments.
- Package build order follows the project references in `tsconfig.build.json`: `vscode-messenger-common` → `vscode-messenger-webview` / `vscode-messenger` → `vscode-messenger-devtools` → its `webview-ui` → `examples/*`. Don't add an import that runs against this direction (e.g. `vscode-messenger-common` importing from `vscode-messenger`).

## Boundaries and definition of done

- Never hand-edit `packages/*/lib/` or `packages/*/webview-ui/build/` — these are build outputs regenerated by `npm run build`.
- Before editing a config file (e.g. `tsconfig.json`, `package.json`), check its `git log` to understand why the lines exist; verify that your change does not alter the published package content — in particular, confirm that `lib/` still receives the same flat output structure after build (incident: #64 — `rootDir` change caused `lib/src/` subdirs and would have broken `"main": "lib/index.js"`).
- Never hand-edit `examples/*/media/web-view-bundle-*.js` — generated by the `browserify` step of `npm run build`.
- The three publishable packages (`vscode-messenger`, `vscode-messenger-webview`, `vscode-messenger-common`) must keep matching version ranges on each other — see [releasing.md](releasing.md) before touching any `package.json` version or dependency range.
- Done means `npm run build`, `npm test`, and `npm run lint` all pass locally; include the final line(s) of each command's output in your response to confirm.
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,45 @@ messenger.sendNotification(colorSelectType, HOST_EXTENSION, 'a85b20');

> **Note:** `messenger.start()` must be called before any messages can be received. Forgetting it causes all incoming messages to be silently dropped.

## Usage in a React webview

When the webview is a React app (e.g. Vite + React), create the `Messenger` **once** at module scope and register handlers inside a `useEffect` that disposes them on cleanup. This is required for React **StrictMode** (which mounts each component twice in development: `setup → cleanup → setup`) and for HMR, both of which re-run effects.

Registering a handler without disposing the previous one has observable consequences: a second **request** handler for the same method throws (`A request handler is already registered for method ...`), and a second **notification** handler stacks and fires twice. The `useEffect` cleanup keeps registrations balanced.

```tsx
// messenger.ts — created exactly once per webview
import { Messenger } from 'vscode-messenger-webview';
export const messenger = new Messenger();

// App.tsx
import { useEffect, useState } from 'react';
import { HOST_EXTENSION } from 'vscode-messenger-common';
import { messenger } from './messenger';
import { colorModifyType, availableColorsType } from './shared/message-types';

export function App() {
const [colors, setColors] = useState<string[]>([]);

useEffect(() => {
const disposables = [
messenger.onNotification(colorModifyType, action => {
if (action === 'clear') setColors([]);
}),
messenger.onRequest(availableColorsType, () => colors),
];
messenger.start(); // idempotent — safe after a StrictMode remount

return () => disposables.forEach(d => d.dispose());
}, []);

return null;
}
```

**Do:** one `Messenger` at module scope, register in `useEffect`, dispose in its cleanup.
**Don't:** call `new Messenger()` or `onRequest`/`onNotification` in the component body — those run on every render and will throw on the second request-handler registration.

## Key concepts

### Message participants
Expand Down
17 changes: 16 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ examples/calico-colors (consumes vscode-messenger + vscode-messenger-webview +

Runtime-agnostic types shared by both ends of the channel.

- `messages.ts` — `NotificationType<P>` / `RequestType<P, R>` type tags, `MessageParticipant` variants, `HOST_EXTENSION`, `BROADCAST`, the wire message shapes.
- `messages.ts` — `NotificationType<P>` / `RequestType<P, R>` type tags, `MessageParticipant` variants, `HOST_EXTENSION`, `BROADCAST`, the wire message shapes, `MessengerAPI` interface, handler types.
- `util.ts` — internal utilities shared by both `Messenger` implementations: `HandlerRegistration`, `HandlerKind`, `participantToString`, `wrongHandlerKindMessage`. Not part of the public user-facing API; consumed only by the two `Messenger` classes.
- `cancellation.ts` — cancellation message types shared between the extension's `vscode.CancellationToken` and the webview's `AbortSignal` bridge.

### `packages/vscode-messenger/`
Expand Down Expand Up @@ -51,3 +52,17 @@ Reference extension (two webview types: `calico-colors-view.ts`, `cat-coding-vie
- `vscode-messenger-common` has no dependency on `vscode` or DOM APIs — it must stay usable from both the extension host and the webview sandbox.
- The devtools introspection contract (`MessengerDiagnostic`) is the only sanctioned way to observe another extension's messenger traffic; it must not require the observed extension to import `vscode-messenger-devtools`.
- `webview-ui` is a `noEmit` TypeScript project (typecheck-only via the root `tsc -b` graph); its actual bundle is produced separately by its own `tsc && vite build` script, invoked from the root `npm run build` — see [AGENTS.md](../AGENTS.md#commands).
- `acquireVsCodeApi()` may be called **at most once** per webview lifetime (VS Code restriction). `vscode-messenger-webview`'s `Messenger` constructor calls it internally. Creating more than one `Messenger` instance per webview would call it twice and throw. Keep the `Messenger` at module scope, outside component trees — see the React example in [README.md](../README.md).
- A `method` string must be used for requests **or** for notifications, never both, on a given side. This is enforced at handler registration time (throws on violation). See [ADR-0004](adr/0004-handler-registration-enforcement.md).
- `Disposable.dispose()` on a handler registration removes exactly that registration by identity — not all registrations for the method. This is a prerequisite for independent notification-handler stacking. See [ADR-0003](adr/0003-disposable-return-from-handler-registration.md).

## Decision records

Key architectural decisions are in [`docs/adr/`](adr/):

| ADR | Decision |
|---|---|
| [0001](adr/0001-two-sided-cancellation-bridge.md) | Two-sided cancellation: `vscode.CancellationToken` on host, `AbortSignal` bridge on webview |
| [0002](adr/0002-webview-participant-union-type.md) | `WebviewMessageParticipant` as discriminated union (not optional fields) |
| [0003](adr/0003-disposable-return-from-handler-registration.md) | `onRequest`/`onNotification` return `Disposable`, not `this` |
| [0004](adr/0004-handler-registration-enforcement.md) | Handler registration throws immediately on conflicting scope or mixed kind (v0.7.0) |
29 changes: 29 additions & 0 deletions docs/adr/0001-two-sided-cancellation-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: accepted
date: 2024-12-01
---

# ADR-0001: Two-sided cancellation bridge — `vscode.CancellationToken` on the host, `AbortSignal` on the webview

## Context

Request cancellation was requested in [#16](https://github.com/TypeFox/vscode-messenger/issues/16). The library spans two runtime environments that have incompatible native cancellation primitives: the VS Code extension host exposes `vscode.CancellationToken` (and `CancellationTokenSource`) as the ecosystem-standard; browser-side code uses `AbortSignal` / `AbortController` from the Web Platform API. A request handler on the host side receives `CancellationToken` from the VS Code runtime itself (e.g. editor commands pass one in). Forcing the webview side to use `CancellationToken` would require shipping a userland implementation of something VS Code provides natively on the host; forcing the host side to use `AbortSignal` would break the VS Code extension contract.

## Options considered

1. **`AbortSignal` on both sides** — avoids a bespoke type; requires a userland `CancellationToken` implementation on the host (reimplements what VS Code provides) and disconnects host handlers from VS Code's own cancellation plumbing.
2. **`vscode.CancellationToken` on both sides** — host stays idiomatic; requires shipping a browser polyfill of `CancellationTokenSource` in the webview package, adding complexity and runtime size.
3. **Native type per side, bridge at the webview boundary (chosen)** — host uses `vscode.CancellationToken` natively; webview calls `createCancellationToken(abortSignal)` to produce a `CancellationToken`-shaped object from any `AbortSignal`.

## Decision

We use `vscode.CancellationToken` as the shared cancellation contract on all request handlers (both host and webview) and provide `createCancellationToken(signal: AbortSignal)` in `vscode-messenger-webview` to bridge the Web Platform primitive into that contract.

The bridge is one-directional (AbortSignal → CancellationToken) and one-line: it keeps the host side idiomatic to VS Code and the webview side idiomatic to the browser without requiring a polyfill in either direction.

## Consequences

- **Easier:** host handlers integrate directly with VS Code's built-in cancellation sources (editor commands, task runners); no extra import needed.
- **Easier:** webview consumers use standard `AbortController`/`AbortSignal` which is already familiar and available without imports.
- **Harder:** webview consumers must import and call `createCancellationToken`; the bridge is not automatic.
- **Follow-up:** the internal `CancellationToken` interface in `vscode-messenger-common` must remain a structural (duck-typed) subset of `vscode.CancellationToken` so both the VS Code-provided token and the bridge's output satisfy it — any narrowing of that interface is a breaking change.
Loading
Loading