diff --git a/AGENTS.md b/AGENTS.md index d72b1169..4e46a85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Monorepo (`pnpm` workspaces + `turbo`). ESM TypeScript; bundled with `tsdown`. P |---------|-----|-------------| | `packages/core` | `@vitejs/devtools` | Vite plugin, CLI, runtime hosts (docks, views, terminals), WS RPC server, standalone/webcomponents client | | `packages/kit` | `@vitejs/devtools-kit` | Public types/utilities for integration authors (`defineRpcFunction`, shared state, events, client helpers) | -| `packages/rpc` | `@vitejs/devtools-rpc` | Typed RPC wrapper over `birpc` with WS presets | +| `packages/rpc` | `@vitejs/devtools-rpc` | Typed RPC wrapper over `birpc`, WS presets, and the peer-mesh layer (`peer/`, `peer/adapters/*`) | | `packages/ui` | `@vitejs/devtools-ui` | Shared UI components, composables, and UnoCSS preset (`presetDevToolsUI`). Private, not published | | `packages/rolldown` | `@vitejs/devtools-rolldown` | Nuxt UI for Rolldown build data. Serves at `/.devtools-rolldown/` | | `packages/vite` | `@vitejs/devtools-vite` | Nuxt UI for Vite DevTools (WIP). Serves at `/.devtools-vite/` | @@ -36,7 +36,8 @@ flowchart TD - **Entry**: `createDevToolsContext` (`packages/core/src/node/context.ts`) builds `DevToolsNodeContext` with hosts for RPC, docks, views, terminals. Invokes `plugin.devtools.setup` hooks. - **Node context**: server-side (cwd, vite config, mode, hosts, auth storage at `node_modules/.vite/devtools/auth.json`). - **Client context**: webcomponents/Nuxt UI state (`packages/core/src/client/webcomponents/state/*`) — dock entries, panels, RPC client. Two modes: `embedded` (overlay in host app) and `standalone` (independent page). -- **WS server** (`packages/core/src/node/ws.ts`): RPC via `@vitejs/devtools-rpc/presets/ws`. Auth skipped in build mode or when `devtools.clientAuth` is `false`. +- **WS server** (`packages/core/src/node/ws.ts`): RPC over a peer mesh with `ws-server` adapter from `@vitejs/devtools-rpc/peer/adapters/ws-server`. Auth skipped in build mode or when `devtools.clientAuth` is `false`. +- **Peer mesh** (`packages/rpc/src/peer/`): pluggable transport abstraction. Every runtime (node server, parent client, standalone, iframe, future Nitro/workers) is a peer with a stable id + role. `PeerMesh` owns a `PeerDirectory` and `LinkTable`. Adapters (currently `ws-server` and `ws-client`) establish links; future phases add `postmessage`, `in-process`, etc. Exposed as `rpc.mesh` on clients and `rpcHost._mesh` on the server. See `docs/kit/peer-mesh.md` for conceptual model and roadmap. - **Nuxt UI plugins** (rolldown, vite, self-inspect): each registers RPC functions and hosts static Nuxt SPA at its own base path. ## Development diff --git a/alias.ts b/alias.ts index b90c7d96..41bb2b69 100644 --- a/alias.ts +++ b/alias.ts @@ -6,6 +6,9 @@ const root = fileURLToPath(new URL('.', import.meta.url)) const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url)) export const alias = { + '@vitejs/devtools-rpc/peer/adapters/ws-server': r('rpc/src/peer/adapters/ws-server.ts'), + '@vitejs/devtools-rpc/peer/adapters/ws-client': r('rpc/src/peer/adapters/ws-client.ts'), + '@vitejs/devtools-rpc/peer': r('rpc/src/peer/index.ts'), '@vitejs/devtools-rpc/presets/ws/server': r('rpc/src/presets/ws/server.ts'), '@vitejs/devtools-rpc/presets/ws/client': r('rpc/src/presets/ws/client.ts'), '@vitejs/devtools-rpc/presets': r('rpc/src/presets/index.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index db98d1f1..194e5f1a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -13,6 +13,7 @@ const DevToolsKitNav = [ { text: 'DevTools Plugin', link: '/kit/devtools-plugin' }, { text: 'Dock System', link: '/kit/dock-system' }, { text: 'RPC', link: '/kit/rpc' }, + { text: 'Peer Mesh', link: '/kit/peer-mesh' }, { text: 'Shared State', link: '/kit/shared-state' }, { text: 'Commands', link: '/kit/commands' }, { text: 'When Clauses', link: '/kit/when-clauses' }, @@ -82,6 +83,7 @@ export default extendConfig(withMermaid(defineConfig({ { text: 'DevTools Plugin', link: '/kit/devtools-plugin' }, { text: 'Dock System', link: '/kit/dock-system' }, { text: 'RPC', link: '/kit/rpc' }, + { text: 'Peer Mesh', link: '/kit/peer-mesh' }, { text: 'Shared State', link: '/kit/shared-state' }, { text: 'Commands', link: '/kit/commands' }, { text: 'When Clauses', link: '/kit/when-clauses' }, diff --git a/docs/kit/peer-mesh.md b/docs/kit/peer-mesh.md new file mode 100644 index 00000000..b6d7ec74 --- /dev/null +++ b/docs/kit/peer-mesh.md @@ -0,0 +1,304 @@ +--- +outline: deep +--- + +# Peer Mesh + +The peer mesh is the communication layer that sits underneath DevTools Kit's [RPC](./rpc) system. It provides a uniform, pluggable foundation for any-to-any messaging between the many runtime contexts that make up a DevTools-enabled Vite application — the devtools server (node), parent client (browser), standalone client (browser), iframe panels (browser), workers, web extensions, and — in future — Nitro runtimes. + +> [!NOTE] +> The peer mesh is currently in **Phase 2**: the abstraction is in place, the existing WebSocket transport flows through it, and **cross-peer calls now work via server relay**. Direct peer-to-peer transports (postMessage, in-process, BroadcastChannel, etc.) are rolling out across subsequent phases. See [Roadmap](#roadmap). + +## Why a mesh? + +Historically every conversation between DevTools components flowed through a single hub — the WebSocket server in the Vite plugin host. That works well for server↔client traffic, but leaves gaps: + +- **Iframe panels cannot reach the parent client directly** — e.g. a panel inspecting the host app's DOM must round-trip through the server. +- **Nitro / plugin server endpoints have no back-channel** to the devtools server. +- **Same-origin peers** (standalone client, iframe) cannot talk to each other at all without server relay. + +The peer mesh addresses these by introducing stable peer identity, pluggable transports, and (in future phases) a router that picks the cheapest available link between any two peers. + +## Conceptual model + +```mermaid +flowchart LR + subgraph Process1["Vite Dev Server (node)"] + Server["Peer: devtools-server"] + end + subgraph Browser["Browser (host tab)"] + Parent["Peer: client:embedded"] + Iframe1["Peer: iframe:rolldown"] + Iframe2["Peer: iframe:self-inspect"] + end + subgraph Standalone["Browser (separate tab)"] + Stand["Peer: client:standalone"] + end + + Server <-->|ws| Parent + Server <-->|ws| Iframe1 + Server <-->|ws| Iframe2 + Server <-->|ws| Stand + Parent <-.postMessage.-> Iframe1 + Parent <-.postMessage.-> Iframe2 + Parent <-.BroadcastChannel.-> Stand +``` + +Every runtime context is a **peer**. Each peer holds a `PeerMesh` — a small object that owns: + +| Piece | Responsibility | +|-------|----------------| +| `self: PeerDescriptor` | Identity + role + capabilities of this process | +| `directory: PeerDirectory` | Registry of known remote peers | +| `links: LinkTable` | Active connections to other peers (one `Link` per transport) | +| `TransportAdapter`s | Pluggable — establish and tear down links | + +Peers are addressed by **role** (e.g. `'iframe:rolldown'`, `'client:embedded'`, `'devtools-server'`) or by **capability** (e.g. `{ capability: 'dom-access' }`). The mesh resolves the address to a concrete peer id, picks the best available link, and dispatches the call. + +### Peer roles + +Built-in role names: + +| Role | Who | +|------|-----| +| `devtools-server` | The Vite plugin host (node) | +| `client:embedded` | The webcomponents client injected into a host app | +| `client:standalone` | The standalone DevTools page | +| `iframe:` | An iframe panel (e.g. `iframe:rolldown`, `iframe:vite`, `iframe:self-inspect`) | +| `worker:` | A Web Worker participating in RPC | +| `nitro:` | A Nitro runtime peer (future) | +| `webext:devtools-panel` | Browser extension devtools page | +| `plugin:` | Plugin-declared custom roles | + +### Transport adapters + +An adapter implements one kind of transport. Adapters register with the mesh; the mesh hands them a context so they can attach links as they establish connections. + +| Adapter | Direction | Status | +|--------|-----------|--------| +| `ws-server` | Node listens for browser/node clients | ✅ Phase 1 | +| `ws-client` | Browser/node connects to devtools-server | ✅ Phase 1 | +| `postmessage` | Parent ↔ iframe (same tab) | Phase 4 | +| `in-process` | Node ↔ node (same process) | Phase 3 | +| `broadcastchannel` | Browser ↔ browser (same origin) | Phase 6 | +| `http` | Node/browser one-shot or polling | Phase 6 | +| `message-channel` | Browser ↔ browser (transferred port) | Phase 6 | +| `comlink-worker` | Main ↔ worker | Phase 6 | + +Adapters are published as subpath exports from `@vitejs/devtools-rpc/peer/adapters/*`, so tree-shaking keeps node-only transports out of the browser bundle. + +## Current state (Phase 2) + +Phase 1 introduced the abstraction. Phase 2 lights up cross-peer messaging: any peer can call any other peer's functions through the devtools-server acting as a relay. Everything that worked before still works — `rpc.call()`, `rpc.callEvent()`, `rpcHost.broadcast()`, shared state, auth — unchanged. What's new: + +- Every `DevToolsRpcClient` carries a `mesh: PeerMesh` field. +- Every server-side `RpcFunctionsHost` has an internal `_mesh` that `createWsServer` wires up. +- The mesh directory is populated automatically: the server is authoritative, clients receive `directory-delta` events and maintain a replica. +- On trust, each client announces its role/capabilities via `devtoolskit:internal:peer:announce`, and the server broadcasts the change to all other peers. +- `rpc.mesh.peer('role-or-id').call('fn', args)` works: if a direct link exists it's used, otherwise the call is wrapped in `devtoolskit:internal:mesh:relay` and forwarded via the server. + +### Inspecting the mesh + +On the browser side: + +```ts +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' + +const rpc = await getDevToolsRpcClient() + +// The mesh this client participates in +console.log(rpc.mesh.self) +// { id: 'peer-xYz...', role: 'client:unknown', capabilities: [], meta: {...}, links: [...] } + +// Peers known to this client (Phase 1: just self + devtools-server) +console.log(rpc.mesh.directory.list()) + +// Active links held by this client +console.log(rpc.mesh.links.all()) +``` + +On the server side you can reach the mesh through the host's internal field: + +```ts +const plugin: Plugin = { + devtools: { + setup(ctx) { + const host = ctx.rpc as any // internal in Phase 1 + const mesh = host._mesh + + // Every connected client appears here + console.log(mesh.directory.list().map(p => p.role)) + // => ['devtools-server', 'client:unknown', 'client:unknown', ...] + + mesh.on('peer:connected', (peer) => { + console.log('peer joined:', peer.id, peer.role) + }) + mesh.on('peer:disconnected', (id) => { + console.log('peer left:', id) + }) + }, + }, +} +``` + +### Declaring a peer role + +The `getDevToolsRpcClient(options)` accepts peer-shaped fields; bootstrap code (e.g. the embedded inject script, standalone entry, iframe panel) should pass its role so the mesh directory reflects what each peer is: + +```ts +const rpc = await getDevToolsRpcClient({ + peerRole: 'iframe:my-plugin', + peerCapabilities: ['dom-access'], + peerMeta: { url: location.href }, +}) +``` + +Right after the client is trusted, it calls `devtoolskit:internal:peer:announce` automatically with the declared role/capabilities/meta; the server updates its directory entry and broadcasts a delta to every other peer, which then apply it to their local replica. + +### Calling another peer + +Once peers have announced, any peer can call another through `rpc.mesh.peer(...)`. The handle transparently picks the best available path: + +```ts +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' + +const rpc = await getDevToolsRpcClient({ peerRole: 'client:standalone' }) + +// Target by role — resolved via the directory replica +await rpc.mesh.peer('iframe:rolldown').call('rolldown:graph:get', { id: 'root' }) + +// Target by role pattern — first match wins +await rpc.mesh.peer('iframe:*').call('theme:changed', { theme: 'dark' }) + +// Target by capability query +await rpc.mesh.peer({ capability: 'dom-access' }).call('dom:snapshot', { selector: '#app' }) + +// Fire-and-forget +rpc.mesh.peer('iframe:rolldown').callEvent('rolldown:refresh') +``` + +In Phase 2 all cross-peer calls are relayed through the devtools-server. That means one server hop each way; direct transports (postMessage, in-process, BroadcastChannel) arrive in later phases and will slot in transparently. + +> [!TIP] +> On the server, you can call any connected peer directly too: +> ```ts +> ctx.rpc._mesh.peer('iframe:rolldown').call('rolldown:graph:get', { id: 'root' }) +> ``` +> Server-originated calls never go through relay — the server already has a direct link to every connected peer. + +### Internal protocol summary + +| Method | Kind | Direction | Purpose | +|--------|------|-----------|---------| +| `devtoolskit:internal:peer:announce` | action | client → server | Client declares its role/capabilities; server upserts directory entry | +| `devtoolskit:internal:peer:directory-delta` | event | server → clients | Broadcast when any peer joins/leaves/updates (sent to all but the subject peer) | +| `devtoolskit:internal:mesh:relay` | action | any → server | Forward an awaited call to another peer | +| `devtoolskit:internal:mesh:relay-event` | event | any → server | Forward a fire-and-forget call to another peer | + +These are implementation details — consumer code should use `rpc.mesh.peer(...)`. + +> [!WARNING] +> In Phase 2, relayed calls arrive at the target with the server's session identity — the origin peer id is not signed across the hop. HMAC-signed origin identity lands in Phase 5. Plugins that need to know the original caller should check that the target peer is the expected one and/or wait for Phase 5 before gating sensitive operations on peer identity. + +## Roadmap + +| Phase | Capability | User-visible impact | +|-------|-----------|---------------------| +| 1 ✅ | Mesh / directory / link foundation, WS transport routed through it | No behavior change; `rpc.mesh` available for inspection | +| 2 ✅ | Peer announce + directory-delta protocol; `mesh:relay` / `mesh:relay-event` RPC functions; `PeerHandle.call` falls back to relay via the server | `rpc.mesh.peer('role').call(...)` works across peers via one server hop | +| 3 | `in-process` adapter | `ctx.rpc.invokeLocal()` skips serialization; unlocks Nitro ↔ devtools-server in-process calls | +| 4 | `postmessage` adapter | Parent ↔ iframe direct, no server hop; unlocks DOM-access use case | +| 5 | Capability gating + HMAC-signed origin identity | Plugins can restrict who can call which methods; trust preserved across relays | +| 6 | Remaining adapters (BroadcastChannel, HTTP, MessageChannel, Comlink) | Same-origin tab-to-tab, edge/serverless Nitro, worker peers | +| 7 | Docs finalized; `presets/ws/*` direct imports deprecated | `@vitejs/devtools-rpc/presets/ws/*` becomes a legacy alias over the peer adapters | + +The plan is **additive** for public APIs — existing callers (`rpc.call()`, `ctx.rpc.broadcast()`, etc.) keep working; new capabilities show up through new APIs. + +## API reference (Phase 2) + +### `PeerMesh` + +```ts +class PeerMesh { + readonly self: PeerDescriptor + readonly directory: PeerDirectory + readonly links: LinkTable + + register(adapter: TransportAdapter, disposer?: () => void): Promise<() => void> + attachLink(link: Link): void + + peer(target: PeerId | PeerRole | PeerRolePattern | PeerQuery): PeerHandle + findPeers(query: PeerQuery): PeerHandle[] + + broadcast(options: { + to?: PeerId | PeerRole | PeerRolePattern | PeerQuery + method: string + args: any[] + event?: boolean + optional?: boolean + }): Promise + + on(event: 'peer:connected' | 'peer:disconnected' | 'peer:updated', fn): () => void +} +``` + +### `PeerDescriptor` + +```ts +interface PeerDescriptor { + id: string + role: PeerRole + capabilities: readonly string[] + meta: Record + links: readonly TransportLinkInfo[] +} +``` + +### `PeerDirectory` + +```ts +class PeerDirectory { + list(): PeerDescriptor[] + get(id: string): PeerDescriptor | undefined + query(q: PeerQuery): PeerDescriptor[] + resolve(target): PeerDescriptor[] + upsert(peer: PeerDescriptor): 'added' | 'updated' + remove(id: string): boolean + onChange(fn): () => void +} +``` + +### `TransportAdapter` + +```ts +interface TransportAdapter { + readonly kind: TransportKind + setup?: (ctx: TransportAdapterContext) => void | Promise + dispose?: () => void | Promise + connect?: (args: TransportConnectArgs) => Promise + canServe?: (local: PeerDescriptor, remote: PeerDescriptor) => boolean +} +``` + +Built-in adapters: + +```ts +import { createWsClientAdapter } from '@vitejs/devtools-rpc/peer/adapters/ws-client' +import { createWsServerAdapter } from '@vitejs/devtools-rpc/peer/adapters/ws-server' +``` + +## Relationship to the RPC layer + +Calls you make through `rpc.call('my:fn', args)` still work exactly as before — the mesh brokers the underlying connection. `rpc.call(...)` is shorthand for `rpc.mesh.peer('devtools-server').call(...)`; the peer-scoped API opens up cross-peer targeting: + +```ts +// Unchanged — call the server +await rpc.call('my-plugin:get-data') + +// Phase 2 (current) — call other peers via server relay +await rpc.mesh.peer('iframe:rolldown').call('rolldown:graph:get', { id }) +await rpc.mesh.peer({ capability: 'dom-access' }).call('dom:snapshot', { selector }) +await rpc.mesh.broadcast({ to: 'iframe:*', method: 'theme:changed', args: [{ theme: 'dark' }] }) +``` + +See [RPC](./rpc) for server/client function definitions and call patterns. diff --git a/docs/kit/rpc.md b/docs/kit/rpc.md index 42f5fa98..7a56f1c6 100644 --- a/docs/kit/rpc.md +++ b/docs/kit/rpc.md @@ -18,6 +18,9 @@ sequenceDiagram Server->>Client: { id, data: '...' } ``` +> [!NOTE] +> The RPC transport sits on top of the [Peer Mesh](./peer-mesh) — a pluggable communication layer that will, in upcoming releases, let any peer (server, parent client, iframe, worker, Nitro runtime…) reach any other. For today's usage the API below is unchanged; the mesh works transparently underneath. + ## Server-Side Functions ### Defining RPC Functions diff --git a/packages/core/src/node/host-functions.ts b/packages/core/src/node/host-functions.ts index b7036269..70d9ff70 100644 --- a/packages/core/src/node/host-functions.ts +++ b/packages/core/src/node/host-functions.ts @@ -1,4 +1,5 @@ import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost } from '@vitejs/devtools-kit' +import type { PeerMesh } from '@vitejs/devtools-rpc/peer' import type { BirpcGroup } from 'birpc' import type { AsyncLocalStorage } from 'node:async_hooks' import { RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' @@ -14,6 +15,10 @@ export class RpcFunctionsHost extends RpcFunctionsCollectorBase = undefined! _asyncStorage: AsyncLocalStorage = undefined! + /** + * @internal + */ + _mesh: PeerMesh = undefined! constructor(context: DevToolsNodeContext) { super(context) diff --git a/packages/core/src/node/rpc/index.ts b/packages/core/src/node/rpc/index.ts index d97d4150..f3f45d90 100644 --- a/packages/core/src/node/rpc/index.ts +++ b/packages/core/src/node/rpc/index.ts @@ -1,5 +1,6 @@ import type { DevToolsDockEntry, DevToolsDocksUserSettings, DevToolsServerCommandEntry, DevToolsTerminalSessionStreamChunkEvent, RpcDefinitionsFilter, RpcDefinitionsToFunctions } from '@vitejs/devtools-kit' import type { SharedStatePatch } from '@vitejs/devtools-kit/utils/shared-state' +import type { PeerDescriptor } from '@vitejs/devtools-rpc/peer' import { anonymousAuth } from './anonymous/auth' import { commandsExecute } from './internal/commands-execute' import { commandsList } from './internal/commands-list' @@ -9,6 +10,9 @@ import { logsClear } from './internal/logs-clear' import { logsList } from './internal/logs-list' import { logsRemove } from './internal/logs-remove' import { logsUpdate } from './internal/logs-update' +import { peerAnnounce } from './internal/mesh/announce' +import { meshRelay } from './internal/mesh/relay' +import { meshRelayEvent } from './internal/mesh/relay-event' import { rpcServerList } from './internal/rpc-server-list' import { sharedStateGet } from './internal/state/get' import { sharedStatePatch } from './internal/state/patch' @@ -39,6 +43,9 @@ export const builtinInternalRpcDeclarations = [ logsList, logsRemove, logsUpdate, + meshRelay, + meshRelayEvent, + peerAnnounce, rpcServerList, sharedStateGet, sharedStatePatch, @@ -71,6 +78,7 @@ declare module '@vitejs/devtools-kit' { export interface DevToolsRpcClientFunctions { 'devtoolskit:internal:auth:revoked': () => Promise 'devtoolskit:internal:logs:updated': () => Promise + 'devtoolskit:internal:peer:directory-delta': (delta: { kind: 'added' | 'updated' | 'removed', peer: PeerDescriptor }) => Promise 'devtoolskit:internal:rpc:client-state:patch': (key: string, patches: SharedStatePatch[], syncId: string) => Promise 'devtoolskit:internal:rpc:client-state:updated': (key: string, fullState: any, syncId: string) => Promise diff --git a/packages/core/src/node/rpc/internal/mesh/announce.ts b/packages/core/src/node/rpc/internal/mesh/announce.ts new file mode 100644 index 00000000..f94270a6 --- /dev/null +++ b/packages/core/src/node/rpc/internal/mesh/announce.ts @@ -0,0 +1,63 @@ +import type { PeerDescriptor } from '@vitejs/devtools-rpc/peer' +import { defineRpcFunction } from '@vitejs/devtools-kit' + +export interface PeerAnnounceInput { + role: string + capabilities?: readonly string[] + meta?: Record +} + +export interface PeerAnnounceResult { + /** The peer id the server assigned to this connection. */ + id: string + /** The current directory snapshot the server sees. */ + directory: PeerDescriptor[] +} + +/** + * `devtoolskit:internal:peer:announce` + * + * Called by each peer after its WS link is trusted. The peer declares its + * role/capabilities/meta; the server updates its directory entry and + * broadcasts a delta to all other peers. Returns the server-assigned peer + * id and the current directory snapshot so the peer can initialise its + * local replica. + */ +export const peerAnnounce = defineRpcFunction({ + name: 'devtoolskit:internal:peer:announce', + type: 'action', + setup: (context) => { + return { + handler: async (input: PeerAnnounceInput): Promise => { + const session = context.rpc.getCurrentRpcSession() + const mesh = context.rpc._mesh + const peerId = session?.meta.peerId + if (!session || !peerId) { + throw new Error('[peer:announce] Missing peer session — cannot announce without an active link') + } + + const existing = mesh.directory.get(peerId) + const descriptor: PeerDescriptor = { + id: peerId, + role: input.role, + capabilities: input.capabilities ?? [], + meta: { + ...(existing?.meta ?? {}), + ...(input.meta ?? {}), + }, + links: existing?.links ?? [{ transport: 'ws', priority: 100 }], + } + + mesh.directory.upsert(descriptor) + + // Also remember role on the session meta for quick access. + session.meta.peerRole = input.role + + return { + id: peerId, + directory: mesh.directory.list(), + } + }, + } + }, +}) diff --git a/packages/core/src/node/rpc/internal/mesh/relay-event.ts b/packages/core/src/node/rpc/internal/mesh/relay-event.ts new file mode 100644 index 00000000..2bc1920d --- /dev/null +++ b/packages/core/src/node/rpc/internal/mesh/relay-event.ts @@ -0,0 +1,29 @@ +import type { MeshRelayInput } from './relay' +import { defineRpcFunction } from '@vitejs/devtools-kit' + +/** + * `devtoolskit:internal:mesh:relay-event` + * + * Fire-and-forget variant of {@link meshRelay}. Resolves immediately; the + * target's handler (if any) runs without an awaited reply. + */ +export const meshRelayEvent = defineRpcFunction({ + name: 'devtoolskit:internal:mesh:relay-event', + type: 'event', + setup: (context) => { + return { + handler: (input: MeshRelayInput): void => { + const mesh = context.rpc._mesh + const matches = mesh.directory.resolve(input.to).filter(p => p.id !== mesh.self.id) + const target = matches[0] + if (!target) + return + const link = mesh.links.pick(target.id) + if (!link) { + return + } + ;(link.rpc as any).$callEvent(input.method, ...input.args) + }, + } + }, +}) diff --git a/packages/core/src/node/rpc/internal/mesh/relay.ts b/packages/core/src/node/rpc/internal/mesh/relay.ts new file mode 100644 index 00000000..9b8a6f5a --- /dev/null +++ b/packages/core/src/node/rpc/internal/mesh/relay.ts @@ -0,0 +1,44 @@ +import type { PeerId, PeerRole, PeerRolePattern } from '@vitejs/devtools-rpc/peer' +import { defineRpcFunction } from '@vitejs/devtools-kit' + +export interface MeshRelayInput { + /** Target peer id, role, or role pattern. First match wins. */ + to: PeerId | PeerRole | PeerRolePattern + /** RPC function name to invoke on the target. */ + method: string + /** Arguments to pass. */ + args: unknown[] +} + +/** + * `devtoolskit:internal:mesh:relay` + * + * Forwards an RPC call from one peer to another through the server. The + * sender must be trusted (enforced by the RPC resolver on non-anonymous + * methods). The server resolves `to` against the directory, picks a link + * to the target, forwards the call, and returns the reply. + * + * Origin identity is not yet signed across relay — that lands in Phase 5. + * For now, relayed calls appear to the target as if sent from the server. + */ +export const meshRelay = defineRpcFunction({ + name: 'devtoolskit:internal:mesh:relay', + type: 'action', + setup: (context) => { + return { + handler: async (input: MeshRelayInput): Promise => { + const mesh = context.rpc._mesh + const matches = mesh.directory.resolve(input.to).filter(p => p.id !== mesh.self.id) + const target = matches[0] + if (!target) { + throw new Error(`[mesh:relay] No peer matching target ${JSON.stringify(input.to)}`) + } + const link = mesh.links.pick(target.id) + if (!link) { + throw new Error(`[mesh:relay] No link available to peer ${target.id}`) + } + return await (link.rpc as any).$call(input.method, ...input.args) + }, + } + }, +}) diff --git a/packages/core/src/node/ws.ts b/packages/core/src/node/ws.ts index 601008d9..75cbc157 100644 --- a/packages/core/src/node/ws.ts +++ b/packages/core/src/node/ws.ts @@ -1,10 +1,12 @@ /* eslint-disable no-console */ import type { ConnectionMeta, DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from '@vitejs/devtools-kit' +import type { PeerDescriptor } from '@vitejs/devtools-rpc/peer' import type { WebSocket } from 'ws' import type { RpcFunctionsHost } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' import process from 'node:process' -import { createWsRpcPreset } from '@vitejs/devtools-rpc/presets/ws/server' +import { PeerMesh } from '@vitejs/devtools-rpc/peer' +import { createWsServerAdapter } from '@vitejs/devtools-rpc/peer/adapters/ws-server' import { createRpcServer } from '@vitejs/devtools-rpc/server' import c from 'ansis' import { getPort } from 'get-port-please' @@ -27,6 +29,7 @@ export interface CreateWsServerOptions { } const ANONYMOUS_SCOPE = 'vite:anonymous:' +const DEVTOOLS_SERVER_PEER_ID = 'devtools-server' export async function createWsServer(options: CreateWsServerOptions) { const rpcHost = options.context.rpc as unknown as RpcFunctionsHost @@ -44,41 +47,13 @@ export async function createWsServer(options: CreateWsServerOptions) { logger.DTK0008().log() } - const preset = createWsRpcPreset({ - port, - host, - https, - onConnected: (ws, req, meta) => { - const url = new URL(req.url ?? '', 'http://localhost') - const authToken = url.searchParams.get('vite_devtools_auth_token') ?? undefined - if (isClientAuthDisabled) { - meta.isTrusted = true - } - else if (authToken && contextInternal.storage.auth.value().trusted[authToken]) { - meta.isTrusted = true - meta.clientAuthToken = authToken - } - else if (authToken && (context.viteConfig.devtools?.config?.clientAuthTokens ?? []).includes(authToken)) { - meta.isTrusted = true - meta.clientAuthToken = authToken - } - - wsClients.add(ws) - const color = meta.isTrusted ? c.green : c.yellow - console.log(color`${MARK_INFO} Websocket client connected. [${meta.id}] [${meta.clientAuthToken}] (${meta.isTrusted ? 'trusted' : 'untrusted'})`) - }, - onDisconnected: (ws, meta) => { - wsClients.delete(ws) - console.log(c.red`${MARK_INFO} Websocket client disconnected. [${meta.id}]`) - }, - }) - const asyncStorage = new AsyncLocalStorage() const rpcGroup = createRpcServer( rpcHost.functions, { - preset, + // The ws-server adapter manages channels, so the preset is a no-op. + preset: () => {}, rpcOptions: { onFunctionError(error, name) { logger.DTK0011({ name }, { cause: error }).log() @@ -116,8 +91,83 @@ export async function createWsServer(options: CreateWsServerOptions) { }, ) + const mesh = new PeerMesh({ + self: { + id: DEVTOOLS_SERVER_PEER_ID, + role: 'devtools-server', + capabilities: ['rpc-relay', 'directory'], + meta: { mode: context.mode, port }, + links: [{ transport: 'ws', priority: 100 }], + }, + }) + + const wsAdapter = createWsServerAdapter({ + port, + host, + https, + rpcGroup, + resolvePeer: (req, meta) => { + const url = new URL(req.url ?? '', 'http://localhost') + const authToken = url.searchParams.get('vite_devtools_auth_token') ?? undefined + if (isClientAuthDisabled) { + meta.isTrusted = true + } + else if (authToken && contextInternal.storage.auth.value().trusted[authToken]) { + meta.isTrusted = true + meta.clientAuthToken = authToken + } + else if (authToken && (context.viteConfig.devtools?.config?.clientAuthTokens ?? []).includes(authToken)) { + meta.isTrusted = true + meta.clientAuthToken = authToken + } + + const descriptor: PeerDescriptor = { + id: authToken ? `peer:${authToken}` : `peer:session-${meta.id}`, + role: 'client:unknown', + capabilities: [], + meta: { + sessionId: meta.id, + authToken: meta.clientAuthToken, + isTrusted: !!meta.isTrusted, + }, + links: [{ transport: 'ws', priority: 100 }], + } + return descriptor + }, + onConnected: (ws, _req, meta) => { + wsClients.add(ws) + const color = meta.isTrusted ? c.green : c.yellow + console.log(color`${MARK_INFO} Websocket client connected. [${meta.id}] [${meta.clientAuthToken}] (${meta.isTrusted ? 'trusted' : 'untrusted'})`) + }, + onDisconnected: (ws, meta) => { + wsClients.delete(ws) + console.log(c.red`${MARK_INFO} Websocket client disconnected. [${meta.id}]`) + }, + }) + + await mesh.register(wsAdapter) + rpcHost._rpcGroup = rpcGroup rpcHost._asyncStorage = asyncStorage + rpcHost._mesh = mesh + + // Broadcast directory changes to all connected peers so they can maintain + // a local replica of the directory. The peer whose entry changed is + // excluded — it already knows its own state. + mesh.directory.onChange((kind, peer) => { + if (peer.id === mesh.self.id) + return + void rpcHost.broadcast({ + method: 'devtoolskit:internal:peer:directory-delta', + args: [{ kind, peer }], + event: true, + optional: true, + filter: (client) => { + const meta = client.$meta as { peerId?: string } | undefined + return meta?.peerId !== peer.id + }, + }) + }) const getConnectionMeta = async (): Promise => { return { @@ -130,6 +180,7 @@ export async function createWsServer(options: CreateWsServerOptions) { port, rpc: rpcGroup, rpcHost, + mesh, getConnectionMeta, } } diff --git a/packages/kit/src/client/rpc-ws.ts b/packages/kit/src/client/rpc-ws.ts index f69f8fa8..e24796e3 100644 --- a/packages/kit/src/client/rpc-ws.ts +++ b/packages/kit/src/client/rpc-ws.ts @@ -1,8 +1,9 @@ +import type { PeerDescriptor } from '@vitejs/devtools-rpc/peer' import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter } from '../types' import type { DevToolsClientRpcHost, RpcClientEvents } from './docks' import type { DevToolsRpcClientMode, DevToolsRpcClientOptions } from './rpc' -import { createRpcClient } from '@vitejs/devtools-rpc/client' -import { createWsRpcPreset } from '@vitejs/devtools-rpc/presets/ws/client' +import { PeerMesh } from '@vitejs/devtools-rpc/peer' +import { createWsClientAdapter } from '@vitejs/devtools-rpc/peer/adapters/ws-client' import { parseUA } from 'ua-parser-modern' import { promiseWithResolver } from '../utils/promise' @@ -13,17 +14,21 @@ export interface CreateWsRpcClientModeOptions { clientRpc: DevToolsClientRpcHost rpcOptions?: DevToolsRpcClientOptions['rpcOptions'] wsOptions?: DevToolsRpcClientOptions['wsOptions'] + /** Self-descriptor for this peer. Provided by the bootstrap layer. */ + self: PeerDescriptor } +const DEVTOOLS_SERVER_PEER_ID = 'devtools-server' + function isNumeric(str: string | number | undefined) { if (str == null) return false return `${+str}` === `${str}` } -export function createWsRpcClientMode( +export async function createWsRpcClientMode( options: CreateWsRpcClientModeOptions, -): DevToolsRpcClientMode { +): Promise { const { authToken, connectionMeta, @@ -31,6 +36,7 @@ export function createWsRpcClientMode( clientRpc, rpcOptions = {}, wsOptions = {}, + self, } = options let isTrusted = false @@ -39,17 +45,15 @@ export function createWsRpcClientMode( ? `${location.protocol.replace('http', 'ws')}//${location.hostname}:${connectionMeta.websocket}` : connectionMeta.websocket as string - const serverRpc = createRpcClient( - clientRpc.functions, - { - preset: createWsRpcPreset({ - url, - authToken, - ...wsOptions, - }), - rpcOptions, - }, - ) + const mesh = new PeerMesh({ self }) + + const remote: PeerDescriptor = { + id: DEVTOOLS_SERVER_PEER_ID, + role: 'devtools-server', + capabilities: ['rpc-relay', 'directory'], + meta: {}, + links: [{ transport: 'ws', priority: 100 }], + } // Handle server-initiated auth revocation clientRpc.register({ @@ -61,8 +65,56 @@ export function createWsRpcClientMode( }, }) + // Apply directory deltas pushed by the server so this peer's mesh.directory + // stays a replica of the authoritative server-side view. + clientRpc.register({ + name: 'devtoolskit:internal:peer:directory-delta', + type: 'event', + handler: async (delta) => { + if (delta.kind === 'removed') { + mesh.directory.remove(delta.peer.id) + } + else { + mesh.directory.upsert(delta.peer) + } + }, + }) + + const adapter = createWsClientAdapter({ + url, + authToken, + clientFunctions: clientRpc.functions, + remote, + rpcOptions, + onConnected: wsOptions.onConnected, + onError: wsOptions.onError, + onDisconnected: wsOptions.onDisconnected, + }) + + await mesh.register(adapter) + const serverHandle = mesh.peer(DEVTOOLS_SERVER_PEER_ID) + let currentAuthToken = authToken + async function announceToServer() { + try { + const result = await serverHandle.call('devtoolskit:internal:peer:announce', { + role: self.role, + capabilities: self.capabilities, + meta: self.meta, + }) as { id: string, directory: PeerDescriptor[] } + // Seed the local directory replica with the server's snapshot so + // subsequent `mesh.peer(role)` calls can resolve immediately. + for (const peer of result.directory) + mesh.directory.upsert(peer) + } + catch { + // Announce is best-effort; if it fails (e.g., older server without + // the endpoint), we degrade to just self + server in the local + // directory — existing behavior. + } + } + async function requestTrustWithToken(token: string) { currentAuthToken = token @@ -76,15 +128,19 @@ export function createWsRpcClientMode( info.device.type, ].filter(i => i).join(' ') - const result = await serverRpc.$call('vite:anonymous:auth', { + const result = await serverHandle.call('vite:anonymous:auth', { authToken: token, ua, origin: location.origin, - }) + }) as { isTrusted: boolean } isTrusted = result.isTrusted trustedPromise.resolve(isTrusted) events.emit('rpc:is-trusted:updated', isTrusted) + + if (isTrusted) + void announceToServer() + return result.isTrusted } @@ -123,22 +179,14 @@ export function createWsRpcClientMode( requestTrustWithToken, ensureTrusted, call: (...args: any): any => { - return serverRpc.$call( - // @ts-expect-error casting - ...args, - ) + return serverHandle.call(...(args as [any, ...any[]])) }, callEvent: (...args: any): any => { - return serverRpc.$callEvent( - // @ts-expect-error casting - ...args, - ) + return serverHandle.callEvent(...(args as [any, ...any[]])) }, callOptional: (...args: any): any => { - return serverRpc.$callOptional( - // @ts-expect-error casting - ...args, - ) + return serverHandle.callOptional(...(args as [any, ...any[]])) }, + mesh, } } diff --git a/packages/kit/src/client/rpc.ts b/packages/kit/src/client/rpc.ts index fd95e678..ad3844ab 100644 --- a/packages/kit/src/client/rpc.ts +++ b/packages/kit/src/client/rpc.ts @@ -1,21 +1,25 @@ import type { RpcCacheOptions } from '@vitejs/devtools-rpc' +import type { PeerDescriptor, PeerRole } from '@vitejs/devtools-rpc/peer' import type { WebSocketRpcClientOptions } from '@vitejs/devtools-rpc/presets/ws/client' import type { BirpcOptions, BirpcReturn } from 'birpc' import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter, RpcSharedStateHost } from '../types' import type { DevToolsClientRpcHost, DevToolsRpcContext, RpcClientEvents } from './docks' import { RpcCacheManager, RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' +import { PeerMesh } from '@vitejs/devtools-rpc/peer' import { DEVTOOLS_CONNECTION_META_FILENAME, DEVTOOLS_MOUNT_PATH, } from '../constants' import { createEventEmitter } from '../utils/events' import { humanId } from '../utils/human-id' +import { nanoid } from '../utils/nanoid' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createWsRpcClientMode } from './rpc-ws' const CONNECTION_META_KEY = '__VITE_DEVTOOLS_CONNECTION_META__' const CONNECTION_AUTH_TOKEN_KEY = '__VITE_DEVTOOLS_CONNECTION_AUTH_TOKEN__' +const CONNECTION_PEER_ID_KEY = '__VITE_DEVTOOLS_CONNECTION_PEER_ID__' export interface DevToolsRpcClientOptions { connectionMeta?: ConnectionMeta @@ -24,6 +28,21 @@ export interface DevToolsRpcClientOptions { * The auth token to use for the client */ authToken?: string + /** + * The role this peer plays in the mesh. + * + * Provided by the bootstrap layer (e.g. `client:embedded`, + * `client:standalone`, `iframe:`). Defaults to `client:unknown`. + */ + peerRole?: PeerRole + /** + * Capabilities this peer advertises to the mesh. + */ + peerCapabilities?: readonly string[] + /** + * Free-form peer meta — included in the peer descriptor. + */ + peerMeta?: Record wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -39,6 +58,15 @@ export interface DevToolsRpcClient { */ events: EventEmitter + /** + * The peer mesh this client participates in. + * + * In WebSocket mode this contains one link (to the devtools-server) and a + * directory replica. In static mode it is a degenerate mesh containing + * only this peer. + */ + readonly mesh: PeerMesh + /** * Whether the client is trusted */ @@ -132,6 +160,40 @@ function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { return value } +/** + * Find or generate a stable peer id for this browser context. + * + * Persisted in localStorage so identity survives reloads; also exposed on + * globalThis so iframes can share the parent's id when appropriate. + */ +function getPeerIdFromWindows(): string { + const getters = [ + () => localStorage.getItem(CONNECTION_PEER_ID_KEY), + () => (window as any)?.[CONNECTION_PEER_ID_KEY], + () => (globalThis as any)?.[CONNECTION_PEER_ID_KEY], + ] + + let value: string | undefined + for (const getter of getters) { + try { + value = getter() + if (value) + break + } + catch {} + } + + if (!value) + value = `peer-${nanoid(16)}` + + try { + localStorage.setItem(CONNECTION_PEER_ID_KEY, value) + } + catch {} + ;(globalThis as any)[CONNECTION_PEER_ID_KEY] = value + return value +} + function findConnectionMetaFromWindows(): ConnectionMeta | undefined { const getters = [ () => (window as any)?.[CONNECTION_META_KEY], @@ -200,8 +262,20 @@ export async function getDevToolsRpcClient( rpc: undefined!, } const authToken = getConnectionAuthTokenFromWindows(options.authToken) + const peerId = getPeerIdFromWindows() const clientRpc: DevToolsClientRpcHost = new RpcFunctionsCollectorBase(context) + const self: PeerDescriptor = { + id: peerId, + role: options.peerRole ?? 'client:unknown', + capabilities: options.peerCapabilities ?? [], + meta: { + authToken, + ...(options.peerMeta ?? {}), + }, + links: connectionMeta.backend === 'static' ? [] : [{ transport: 'ws', priority: 100 }], + } + async function fetchJsonFromBases(path: string): Promise { const candidates = [ resolvedBaseURL, @@ -228,39 +302,48 @@ export async function getDevToolsRpcClient( }) } - const mode = connectionMeta.backend === 'static' - ? await createStaticRpcClientMode({ - fetchJsonFromBases, - }) - : createWsRpcClientMode({ - authToken, - connectionMeta, - events, - clientRpc, - rpcOptions: { - ...rpcOptions, - async onRequest(req, next, resolve) { - await rpcOptions.onRequest?.call(this, req, next, resolve) - if (cacheOptions && cacheManager?.validate(req.m)) { - const cached = cacheManager.cached(req.m, req.a) - if (cached) { - return resolve(cached) - } - else { - const res = await next(req) - cacheManager?.apply(req, res) - } + let mesh: PeerMesh + let mode: Awaited> | Awaited> + + if (connectionMeta.backend === 'static') { + mode = await createStaticRpcClientMode({ fetchJsonFromBases }) + mesh = new PeerMesh({ self }) + } + else { + const wsMode = await createWsRpcClientMode({ + authToken, + connectionMeta, + events, + clientRpc, + self, + rpcOptions: { + ...rpcOptions, + async onRequest(req, next, resolve) { + await rpcOptions.onRequest?.call(this, req, next, resolve) + if (cacheOptions && cacheManager?.validate(req.m)) { + const cached = cacheManager.cached(req.m, req.a) + if (cached) { + return resolve(cached) } else { - await next(req) + const res = await next(req) + cacheManager?.apply(req, res) } - }, + } + else { + await next(req) + } }, - wsOptions: options.wsOptions, - }) + }, + wsOptions: options.wsOptions, + }) + mode = wsMode + mesh = wsMode.mesh + } const rpc: DevToolsRpcClient = { events, + mesh, get isTrusted() { return mode.isTrusted }, diff --git a/packages/kit/src/types/rpc.ts b/packages/kit/src/types/rpc.ts index a9a13594..42b268ec 100644 --- a/packages/kit/src/types/rpc.ts +++ b/packages/kit/src/types/rpc.ts @@ -1,4 +1,5 @@ import type { RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' +import type { PeerMesh } from '@vitejs/devtools-rpc/peer' import type { DevToolsNodeRpcSessionMeta } from '@vitejs/devtools-rpc/presets/ws/server' import type { BirpcReturn } from 'birpc' import type { SharedState } from '../utils/shared-state' @@ -55,6 +56,17 @@ export type RpcFunctionsHost = RpcFunctionsCollectorBase { diff --git a/packages/rpc/README.md b/packages/rpc/README.md index 51226add..73a737fe 100644 --- a/packages/rpc/README.md +++ b/packages/rpc/README.md @@ -12,6 +12,7 @@ DevTools RPC for Vite, featuring extensible [birpc](https://github.com/antfu-col - **Dump feature** for pre-computing results (static hosting, testing, offline mode) - **Basic RPC Client/Server** built on birpc - **WebSocket Presets** ready-to-use transport presets +- **Peer mesh** pluggable multi-transport layer (`peer/*`) with `ws-server` and `ws-client` adapters; more adapters (postMessage, in-process, BroadcastChannel, HTTP) landing incrementally ## Installation @@ -188,6 +189,7 @@ Set `concurrency` to `true` for parallel execution (default limit: 5) or a numbe - **`.`** - Type-safe function definitions and utilities (main export) - `RpcFunctionsCollectorBase`, `defineRpcFunction`, `createDefineWrapperWithContext` - `dumpFunctions`, `createClientFromDump`, `RpcCacheManager` + - Also re-exports the peer mesh types and classes - Type definitions and utilities - **`./client`** - RPC client @@ -205,6 +207,16 @@ Set `concurrency` to `true` for parallel execution (default limit: 5) or a numbe - **`./presets/ws/server`** - WebSocket server preset - `createWsRpcPreset` +- **`./peer`** - Peer mesh layer + - `PeerMesh`, `PeerDirectory`, `LinkTable`, `createLink`, `matchPeer`, `matchRolePattern` + - Types: `PeerDescriptor`, `PeerHandle`, `PeerQuery`, `TransportAdapter`, `Envelope`, `AuthContext` + +- **`./peer/adapters/ws-client`** - WebSocket transport adapter (browser/node client) + - `createWsClientAdapter` + +- **`./peer/adapters/ws-server`** - WebSocket transport adapter (node server) + - `createWsServerAdapter` + ## Examples See [src/examples](./src/examples) and [test files](./src) for complete integration examples. diff --git a/packages/rpc/package.json b/packages/rpc/package.json index 2ae4c412..9ae21da1 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -21,6 +21,9 @@ "exports": { ".": "./dist/index.mjs", "./client": "./dist/client.mjs", + "./peer": "./dist/peer/index.mjs", + "./peer/adapters/ws-client": "./dist/peer/adapters/ws-client.mjs", + "./peer/adapters/ws-server": "./dist/peer/adapters/ws-server.mjs", "./presets": "./dist/presets/index.mjs", "./presets/ws/client": "./dist/presets/ws/client.mjs", "./presets/ws/server": "./dist/presets/ws/server.mjs", diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index ef021396..8b5d28b9 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -3,5 +3,6 @@ export * from './collector' export * from './define' export * from './dumps' export * from './handler' +export * from './peer' export * from './types' export * from './validation' diff --git a/packages/rpc/src/peer/adapters/index.ts b/packages/rpc/src/peer/adapters/index.ts new file mode 100644 index 00000000..1590fc97 --- /dev/null +++ b/packages/rpc/src/peer/adapters/index.ts @@ -0,0 +1,2 @@ +export * from './ws-client' +export * from './ws-server' diff --git a/packages/rpc/src/peer/adapters/ws-client.ts b/packages/rpc/src/peer/adapters/ws-client.ts new file mode 100644 index 00000000..919b399e --- /dev/null +++ b/packages/rpc/src/peer/adapters/ws-client.ts @@ -0,0 +1,139 @@ +import type { BirpcOptions, BirpcReturn, ChannelOptions } from 'birpc' +import type { PeerDescriptor, TransportAdapter } from '../types' +import { createBirpc } from 'birpc' +import { parse, stringify } from 'structured-clone-es' +import { createLink } from '../link' + +export interface WsClientAdapterOptions< + ServerFunctions extends object, + ClientFunctions extends object, +> { + url: string + authToken?: string + /** Functions this peer exposes to the server. */ + clientFunctions: ClientFunctions + /** + * Initial descriptor for the server peer. May be refined later via the + * directory/hello handshake; for Phase 1 a well-known shape is fine. + */ + remote: PeerDescriptor + rpcOptions?: Partial> + onConnected?: (e: Event) => void + onError?: (e: Error) => void + onDisconnected?: (e: CloseEvent) => void + /** + * Optional callback invoked with the live birpc handle after the link is + * attached. The kit-level client mode uses this to call + * `vite:anonymous:auth` and install client-side event handlers. + */ + onHandle?: (rpc: BirpcReturn) => void +} + +/** + * Create a transport adapter that opens a single outgoing WebSocket + * connection to the devtools-server and registers it as a Link. + */ +export function createWsClientAdapter< + ServerFunctions extends object = Record, + ClientFunctions extends object = Record, +>(options: WsClientAdapterOptions): TransportAdapter { + const { + url, + authToken, + clientFunctions, + remote, + rpcOptions = {}, + onConnected, + onError, + onDisconnected, + onHandle, + } = options + + let closed = false + let cleanup: (() => void) | undefined + + const adapter: TransportAdapter = { + kind: 'ws', + canServe(_local, r) { + return r.id === remote.id + }, + + async setup(ctx) { + const wsUrl = authToken + ? `${url}?vite_devtools_auth_token=${encodeURIComponent(authToken)}` + : url + const ws = new WebSocket(wsUrl) + + ws.addEventListener('open', (e) => { + onConnected?.(e) + }) + ws.addEventListener('error', (e) => { + const _e = e instanceof Error ? e : new Error(e.type) + onError?.(_e) + }) + ws.addEventListener('close', (e) => { + onDisconnected?.(e) + }) + + const channel: ChannelOptions = { + on: (handler) => { + ws.addEventListener('message', (e) => { + handler(e.data) + }) + }, + post: (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + else { + function handler() { + ws.send(data) + ws.removeEventListener('open', handler) + } + ws.addEventListener('open', handler) + } + }, + serialize: stringify, + deserialize: parse, + } + + const rpc = createBirpc( + clientFunctions, + { + ...channel, + timeout: -1, + ...rpcOptions, + proxify: false, + }, + ) + + const link = createLink({ + id: `ws-client:${remote.id}`, + remote, + kind: 'ws', + rpc: rpc as BirpcReturn, + isDirect: true, + }) + + ctx.attachLink(link) + onHandle?.(rpc) + + cleanup = () => { + if (closed) + return + closed = true + try { + ws.close() + } + catch {} + link.close() + } + }, + + async dispose() { + cleanup?.() + }, + } + + return adapter +} diff --git a/packages/rpc/src/peer/adapters/ws-server.ts b/packages/rpc/src/peer/adapters/ws-server.ts new file mode 100644 index 00000000..cf34c884 --- /dev/null +++ b/packages/rpc/src/peer/adapters/ws-server.ts @@ -0,0 +1,158 @@ +import type { BirpcGroup, BirpcOptions, ChannelOptions } from 'birpc' +import type { IncomingMessage } from 'node:http' +import type { ServerOptions as HttpsServerOptions } from 'node:https' +import type { WebSocket } from 'ws' +import type { DevToolsNodeRpcSessionMeta } from '../../presets/ws/server' +import type { Link } from '../link' +import type { PeerDescriptor, TransportAdapter } from '../types' +import { createServer as createHttpsServer } from 'node:https' +import { parse, stringify } from 'structured-clone-es' +import { WebSocketServer } from 'ws' +import { createLink } from '../link' + +/** + * Session metadata tracked per WebSocket connection. + * + * Re-exported from the legacy preset so callers that read these fields + * (auth-revoke, ws logs, etc.) continue to work. + */ +export type WsServerSessionMeta = DevToolsNodeRpcSessionMeta + +export interface WsServerAdapterOptions< + ClientFunctions extends object, + ServerFunctions extends object, +> { + port: number + host?: string + https?: HttpsServerOptions | undefined + /** + * The BirpcGroup that tracks all incoming channels. The adapter adds each + * accepted connection's channel to the group. + */ + rpcGroup: BirpcGroup + /** + * Build the PeerDescriptor for an incoming connection. + * + * The default strategy derives role/id from query params, but consumers + * typically replace this with auth-driven logic. + */ + resolvePeer: ( + req: IncomingMessage, + meta: WsServerSessionMeta, + ) => PeerDescriptor | Promise + serialize?: BirpcOptions['serialize'] + deserialize?: BirpcOptions['deserialize'] + onConnected?: (ws: WebSocket, req: IncomingMessage, meta: WsServerSessionMeta) => void + onDisconnected?: (ws: WebSocket, meta: WsServerSessionMeta) => void +} + +let sessionIdCounter = 0 + +/** + * Create a transport adapter that listens for WebSocket connections and + * registers each as a Link in the mesh. + */ +export function createWsServerAdapter< + ClientFunctions extends object, + ServerFunctions extends object, +>(options: WsServerAdapterOptions): TransportAdapter { + const { + port, + host = 'localhost', + https, + rpcGroup, + resolvePeer, + serialize = stringify, + deserialize = parse, + onConnected, + onDisconnected, + } = options + + const httpsServer = https ? createHttpsServer(https) : undefined + const wss = https + ? new WebSocketServer({ server: httpsServer }) + : new WebSocketServer({ port, host }) + + const activeLinks: Set = new Set() + + const adapter: TransportAdapter = { + kind: 'ws', + + async setup(ctx) { + wss.on('connection', async (ws, req) => { + const meta: WsServerSessionMeta = { + id: sessionIdCounter++, + ws, + subscribedStates: new Set(), + } + + const channel: ChannelOptions = { + post: (data) => { + ws.send(data) + }, + on: (fn) => { + ws.on('message', (data) => { + fn(data.toString()) + }) + }, + serialize, + deserialize, + meta, + } + + // Resolve peer descriptor (typically inspects auth-token in query) + const remote = await resolvePeer(req, meta) + meta.peerId = remote.id + meta.peerRole = remote.role + + // Add channel to the birpc group and grab the resulting birpc handle. + rpcGroup.updateChannels((channels) => { + channels.push(channel) + }) + const rpcHandle = rpcGroup.clients.find(c => c.$meta === meta) + if (!rpcHandle) { + // Should not happen — updateChannels just added it + ws.close() + return + } + + const link = createLink({ + id: `ws:${meta.id}`, + remote, + kind: 'ws', + rpc: rpcHandle, + isDirect: true, + meta: { sessionMeta: meta }, + }) + + activeLinks.add(link) + ctx.attachLink(link) + onConnected?.(ws, req, meta) + + ws.on('close', () => { + rpcGroup.updateChannels((channels) => { + const index = channels.indexOf(channel) + if (index >= 0) + channels.splice(index, 1) + }) + activeLinks.delete(link) + link.close() + onDisconnected?.(ws, meta) + }) + }) + + if (httpsServer) + httpsServer.listen(port, host) + }, + + async dispose() { + for (const link of activeLinks) + link.close() + activeLinks.clear() + wss.close() + httpsServer?.close() + }, + } + + return adapter +} diff --git a/packages/rpc/src/peer/directory.test.ts b/packages/rpc/src/peer/directory.test.ts new file mode 100644 index 00000000..1237b587 --- /dev/null +++ b/packages/rpc/src/peer/directory.test.ts @@ -0,0 +1,135 @@ +import type { PeerDescriptor } from './types' +import { describe, expect, it, vi } from 'vitest' +import { matchPeer, matchRolePattern, PeerDirectory } from './directory' + +function makePeer(partial: Partial & { id: string, role: string }): PeerDescriptor { + return { + capabilities: [], + meta: {}, + links: [], + ...partial, + } +} + +describe('matchRolePattern', () => { + it('matches exact role', () => { + expect(matchRolePattern('iframe:rolldown', 'iframe:rolldown')).toBe(true) + expect(matchRolePattern('iframe:rolldown', 'iframe:vite')).toBe(false) + }) + + it('matches wildcard prefix patterns', () => { + expect(matchRolePattern('iframe:rolldown', 'iframe:*')).toBe(true) + expect(matchRolePattern('iframe:vite', 'iframe:*')).toBe(true) + expect(matchRolePattern('client:embedded', 'iframe:*')).toBe(false) + }) + + it('matches global wildcard', () => { + expect(matchRolePattern('anything:here', '*')).toBe(true) + expect(matchRolePattern('devtools-server', '*')).toBe(true) + }) +}) + +describe('matchPeer', () => { + const peer = makePeer({ + id: 'p1', + role: 'iframe:rolldown', + capabilities: ['dom-access'], + meta: { plugin: 'rolldown', version: '2' }, + }) + + it('matches by role', () => { + expect(matchPeer(peer, { role: 'iframe:rolldown' })).toBe(true) + expect(matchPeer(peer, { role: 'iframe:*' })).toBe(true) + expect(matchPeer(peer, { role: 'client:*' })).toBe(false) + }) + + it('matches by capability', () => { + expect(matchPeer(peer, { capability: 'dom-access' })).toBe(true) + expect(matchPeer(peer, { capability: 'fs-read' })).toBe(false) + }) + + it('matches by meta', () => { + expect(matchPeer(peer, { meta: { plugin: 'rolldown' } })).toBe(true) + expect(matchPeer(peer, { meta: { plugin: 'vite' } })).toBe(false) + }) + + it('matches combined query', () => { + expect(matchPeer(peer, { role: 'iframe:*', capability: 'dom-access' })).toBe(true) + expect(matchPeer(peer, { role: 'iframe:*', capability: 'fs-read' })).toBe(false) + }) +}) + +describe('peerDirectory', () => { + it('upserts, lists, gets, removes', () => { + const dir = new PeerDirectory() + dir.upsert(makePeer({ id: 'a', role: 'client:embedded' })) + dir.upsert(makePeer({ id: 'b', role: 'iframe:rolldown' })) + + expect(dir.list()).toHaveLength(2) + expect(dir.get('a')?.role).toBe('client:embedded') + expect(dir.has('b')).toBe(true) + + expect(dir.remove('a')).toBe(true) + expect(dir.list()).toHaveLength(1) + expect(dir.has('a')).toBe(false) + }) + + it('distinguishes add vs update in upsert result', () => { + const dir = new PeerDirectory() + expect(dir.upsert(makePeer({ id: 'a', role: 'client:embedded' }))).toBe('added') + expect(dir.upsert(makePeer({ id: 'a', role: 'client:standalone' }))).toBe('updated') + }) + + it('queries by role pattern', () => { + const dir = new PeerDirectory() + dir.upsert(makePeer({ id: 'a', role: 'iframe:rolldown' })) + dir.upsert(makePeer({ id: 'b', role: 'iframe:vite' })) + dir.upsert(makePeer({ id: 'c', role: 'client:embedded' })) + + const iframes = dir.query({ role: 'iframe:*' }) + expect(iframes.map(p => p.id).sort()).toEqual(['a', 'b']) + }) + + it('resolve handles id, role, and pattern', () => { + const dir = new PeerDirectory() + dir.upsert(makePeer({ id: 'a', role: 'iframe:rolldown' })) + dir.upsert(makePeer({ id: 'b', role: 'iframe:vite' })) + + expect(dir.resolve('a').map(p => p.id)).toEqual(['a']) + expect(dir.resolve('iframe:rolldown').map(p => p.id)).toEqual(['a']) + expect(dir.resolve('iframe:*').map(p => p.id).sort()).toEqual(['a', 'b']) + expect(dir.resolve({ role: 'iframe:*' }).map(p => p.id).sort()).toEqual(['a', 'b']) + }) + + it('emits change events', () => { + const dir = new PeerDirectory() + const listener = vi.fn() + dir.onChange(listener) + + const peer = makePeer({ id: 'a', role: 'client:embedded' }) + dir.upsert(peer) + expect(listener).toHaveBeenCalledWith('added', peer) + + listener.mockClear() + const updated = { ...peer, role: 'client:standalone' } + dir.upsert(updated) + expect(listener).toHaveBeenCalledWith('updated', updated) + + listener.mockClear() + dir.remove('a') + expect(listener).toHaveBeenCalledWith('removed', updated) + }) + + it('clear empties and emits removed for each', () => { + const dir = new PeerDirectory() + dir.upsert(makePeer({ id: 'a', role: 'client:embedded' })) + dir.upsert(makePeer({ id: 'b', role: 'iframe:rolldown' })) + + const listener = vi.fn() + dir.onChange(listener) + dir.clear() + + expect(dir.list()).toHaveLength(0) + expect(listener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/rpc/src/peer/directory.ts b/packages/rpc/src/peer/directory.ts new file mode 100644 index 00000000..379a0dfa --- /dev/null +++ b/packages/rpc/src/peer/directory.ts @@ -0,0 +1,107 @@ +import type { PeerDescriptor, PeerId, PeerQuery, PeerRole, PeerRolePattern } from './types' + +/** + * Match a role against a pattern like `iframe:*` or `*`. + */ +export function matchRolePattern(role: PeerRole, pattern: PeerRole | PeerRolePattern): boolean { + if (pattern === '*' || pattern === role) + return true + if (pattern.endsWith(':*')) { + const prefix = pattern.slice(0, -1) + return role.startsWith(prefix) + } + return false +} + +/** + * Check whether a descriptor matches a query. + */ +export function matchPeer(peer: PeerDescriptor, query: PeerQuery): boolean { + if (query.role && !matchRolePattern(peer.role, query.role)) + return false + if (query.capability && !peer.capabilities.includes(query.capability)) + return false + if (query.meta) { + for (const key of Object.keys(query.meta)) { + if (peer.meta[key] !== query.meta[key]) + return false + } + } + return true +} + +type DirectoryChangeKind = 'added' | 'removed' | 'updated' + +/** + * A registry of known peers in the mesh. + * + * On the devtools-server, this is authoritative. On other peers, this is an + * eventually-consistent replica populated by the server's + * `directory-delta` broadcasts. + */ +export class PeerDirectory { + private readonly peers: Map = new Map() + private readonly listeners: Set<(kind: DirectoryChangeKind, peer: PeerDescriptor) => void> = new Set() + + list(): PeerDescriptor[] { + return Array.from(this.peers.values()) + } + + get(id: PeerId): PeerDescriptor | undefined { + return this.peers.get(id) + } + + has(id: PeerId): boolean { + return this.peers.has(id) + } + + query(q: PeerQuery): PeerDescriptor[] { + return this.list().filter(peer => matchPeer(peer, q)) + } + + /** + * Resolve a target (id, role, or query) to a set of concrete peers. + */ + resolve(target: PeerId | PeerRole | PeerRolePattern | PeerQuery): PeerDescriptor[] { + if (typeof target === 'object') + return this.query(target) + const byId = this.peers.get(target) + if (byId) + return [byId] + return this.list().filter(peer => matchRolePattern(peer.role, target as PeerRolePattern)) + } + + upsert(peer: PeerDescriptor): DirectoryChangeKind { + const existing = this.peers.get(peer.id) + this.peers.set(peer.id, peer) + const kind: DirectoryChangeKind = existing ? 'updated' : 'added' + this.emit(kind, peer) + return kind + } + + remove(id: PeerId): boolean { + const existing = this.peers.get(id) + if (!existing) + return false + this.peers.delete(id) + this.emit('removed', existing) + return true + } + + clear(): void { + const snapshot = this.list() + this.peers.clear() + for (const peer of snapshot) + this.emit('removed', peer) + } + + onChange(fn: (kind: DirectoryChangeKind, peer: PeerDescriptor) => void): () => void { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + + private emit(kind: DirectoryChangeKind, peer: PeerDescriptor): void { + for (const fn of this.listeners) + fn(kind, peer) + } +} diff --git a/packages/rpc/src/peer/index.ts b/packages/rpc/src/peer/index.ts new file mode 100644 index 00000000..c2bbb619 --- /dev/null +++ b/packages/rpc/src/peer/index.ts @@ -0,0 +1,4 @@ +export * from './directory' +export * from './link' +export * from './mesh' +export * from './types' diff --git a/packages/rpc/src/peer/link.ts b/packages/rpc/src/peer/link.ts new file mode 100644 index 00000000..e23e3257 --- /dev/null +++ b/packages/rpc/src/peer/link.ts @@ -0,0 +1,125 @@ +import type { BirpcReturn } from 'birpc' +import type { PeerDescriptor, PeerId, TransportKind } from './types' + +/** + * A Link is one established RPC connection between the local peer and one + * remote peer, over one transport. In Phase 1 (no routing), each Link + * directly wraps a birpc instance. + * + * Links are created by adapters during connect/listen and tracked by the + * PeerMesh. + */ +export interface Link { + readonly id: string + readonly remote: PeerDescriptor + readonly kind: TransportKind + readonly isDirect: boolean + /** The raw birpc handle — used internally by PeerHandle to dispatch calls. */ + readonly rpc: BirpcReturn + readonly meta: Record + close: () => void + onClose: (fn: () => void) => () => void +} + +export interface CreateLinkOptions { + id: string + remote: PeerDescriptor + kind: TransportKind + rpc: BirpcReturn + isDirect?: boolean + meta?: Record + onClose?: () => void +} + +/** + * Create a Link object for tracking in the mesh. + */ +export function createLink(options: CreateLinkOptions): Link { + const closeListeners = new Set<() => void>() + let closed = false + + const close = (): void => { + if (closed) + return + closed = true + options.onClose?.() + for (const fn of closeListeners) + fn() + closeListeners.clear() + } + + return { + id: options.id, + remote: options.remote, + kind: options.kind, + isDirect: options.isDirect ?? true, + rpc: options.rpc, + meta: options.meta ?? {}, + close, + onClose(fn) { + closeListeners.add(fn) + return () => closeListeners.delete(fn) + }, + } +} + +/** + * A table of links keyed by remote peer id. + * + * The mesh keeps one of these per local peer; each remote peer may have + * multiple links across different transports — higher-priority links are + * preferred. + */ +export class LinkTable { + private readonly byPeer: Map = new Map() + + add(link: Link): void { + const list = this.byPeer.get(link.remote.id) ?? [] + list.push(link) + this.byPeer.set(link.remote.id, list) + } + + remove(link: Link): void { + const list = this.byPeer.get(link.remote.id) + if (!list) + return + const idx = list.indexOf(link) + if (idx >= 0) + list.splice(idx, 1) + if (list.length === 0) + this.byPeer.delete(link.remote.id) + } + + get(id: PeerId): Link[] { + return this.byPeer.get(id) ?? [] + } + + /** + * Pick the best link for reaching a peer. "Best" = direct > indirect, then + * ordered by the transport priority declared on the remote descriptor. + */ + pick(id: PeerId): Link | undefined { + const links = this.get(id) + if (links.length === 0) + return undefined + if (links.length === 1) + return links[0] + return [...links].sort((a, b) => { + if (a.isDirect !== b.isDirect) + return a.isDirect ? -1 : 1 + return 0 + })[0] + } + + has(id: PeerId): boolean { + return this.byPeer.has(id) + } + + all(): Link[] { + return Array.from(this.byPeer.values()).flat() + } + + clear(): void { + this.byPeer.clear() + } +} diff --git a/packages/rpc/src/peer/mesh.test.ts b/packages/rpc/src/peer/mesh.test.ts new file mode 100644 index 00000000..cad9cfb7 --- /dev/null +++ b/packages/rpc/src/peer/mesh.test.ts @@ -0,0 +1,208 @@ +import type { BirpcReturn } from 'birpc' +import type { Link } from './link' +import type { PeerDescriptor } from './types' +import { describe, expect, it, vi } from 'vitest' +import { createLink } from './link' +import { PeerMesh } from './mesh' + +function makePeer(id: string, role: string, extra: Partial = {}): PeerDescriptor { + return { + id, + role, + capabilities: [], + meta: {}, + links: [], + ...extra, + } +} + +function makeFakeRpc(responses: Record any> = {}): { + rpc: BirpcReturn + calls: { method: string, args: any[], kind: 'call' | 'event' | 'optional' | 'raw' }[] +} { + const calls: { method: string, args: any[], kind: 'call' | 'event' | 'optional' | 'raw' }[] = [] + const rpc = { + $call: async (method: string, ...args: any[]) => { + calls.push({ method, args, kind: 'call' }) + return responses[method]?.(...args) + }, + $callEvent: (method: string, ...args: any[]) => { + calls.push({ method, args, kind: 'event' }) + responses[method]?.(...args) + }, + $callOptional: async (method: string, ...args: any[]) => { + calls.push({ method, args, kind: 'optional' }) + return responses[method]?.(...args) + }, + $callRaw: async ({ method, args }: { method: string, args: any[] }) => { + calls.push({ method, args, kind: 'raw' }) + return responses[method]?.(...args) + }, + $meta: {}, + } as unknown as BirpcReturn + return { rpc, calls } +} + +function makeLink(remote: PeerDescriptor, rpc: BirpcReturn): Link { + return createLink({ + id: `test:${remote.id}`, + remote, + kind: 'ws', + rpc, + isDirect: true, + }) +} + +describe('peerMesh', () => { + it('seeds directory with self', () => { + const self = makePeer('self', 'client:embedded') + const mesh = new PeerMesh({ self }) + expect(mesh.directory.list()).toHaveLength(1) + expect(mesh.directory.get('self')).toEqual(self) + }) + + it('registers adapter and runs setup', async () => { + const self = makePeer('self', 'client:embedded') + const mesh = new PeerMesh({ self }) + const setup = vi.fn() + await mesh.register({ kind: 'ws', setup }) + expect(setup).toHaveBeenCalledTimes(1) + const ctx = setup.mock.calls[0]![0] + expect(ctx.self).toEqual(self) + expect(typeof ctx.attachLink).toBe('function') + }) + + it('attachLink upserts peer and emits connected', async () => { + const self = makePeer('self', 'client:embedded') + const mesh = new PeerMesh({ self }) + const connectedListener = vi.fn() + mesh.on('peer:connected', connectedListener) + + const remote = makePeer('remote', 'devtools-server') + const { rpc } = makeFakeRpc() + const link = makeLink(remote, rpc) + mesh.attachLink(link) + + expect(mesh.directory.get('remote')).toEqual(remote) + expect(mesh.links.all()).toContain(link) + expect(connectedListener).toHaveBeenCalledWith(remote) + }) + + it('link close removes peer from directory and emits disconnected', async () => { + const self = makePeer('self', 'client:embedded') + const mesh = new PeerMesh({ self }) + const disconnectedListener = vi.fn() + mesh.on('peer:disconnected', disconnectedListener) + + const remote = makePeer('remote', 'devtools-server') + const { rpc } = makeFakeRpc() + const link = makeLink(remote, rpc) + mesh.attachLink(link) + link.close() + + expect(mesh.directory.has('remote')).toBe(false) + expect(disconnectedListener).toHaveBeenCalledWith('remote') + }) +}) + +describe('peerMesh.peer — direct vs relay', () => { + function buildMesh(): { + mesh: PeerMesh + serverCalls: ReturnType['calls'] + serverResponses: Record any> + } { + const self = makePeer('client-a', 'client:embedded') + const mesh = new PeerMesh({ self }) + + // Direct link to the devtools-server + const server = makePeer('devtools-server', 'devtools-server') + const serverResponses: Record any> = {} + const { rpc: serverRpc, calls: serverCalls } = makeFakeRpc(serverResponses) + const serverLink = makeLink(server, serverRpc) + mesh.attachLink(serverLink) + + return { mesh, serverCalls, serverResponses } + } + + it('uses direct link when available', async () => { + const { mesh, serverCalls, serverResponses } = buildMesh() + serverResponses['vite:config:get'] = () => ({ root: '/home/app' }) + + const result = await mesh.peer('devtools-server').call('vite:config:get') + expect(result).toEqual({ root: '/home/app' }) + + expect(serverCalls).toHaveLength(1) + expect(serverCalls[0]!.method).toBe('vite:config:get') + expect(serverCalls[0]!.kind).toBe('call') + }) + + it('falls back to relay via server when no direct link exists', async () => { + const { mesh, serverCalls, serverResponses } = buildMesh() + + // Add iframe:rolldown to the directory but no direct link + mesh.directory.upsert(makePeer('peer-rolldown', 'iframe:rolldown')) + + serverResponses['devtoolskit:internal:mesh:relay'] = (input: any) => { + // Simulate the server receiving the relay and forwarding — in the + // real implementation the server would call the target's link; here + // we just echo the inputs back so we can assert. + return { ok: true, input } + } + + const result = await mesh.peer('iframe:rolldown').call('rolldown:get-data', { id: 'x' }) as any + expect(result.ok).toBe(true) + expect(result.input).toEqual({ + to: 'iframe:rolldown', + method: 'rolldown:get-data', + args: [{ id: 'x' }], + }) + + const relayCall = serverCalls.find(c => c.method === 'devtoolskit:internal:mesh:relay') + expect(relayCall).toBeDefined() + }) + + it('callEvent uses relay-event fallback when no direct link', () => { + const { mesh, serverCalls } = buildMesh() + mesh.directory.upsert(makePeer('peer-rolldown', 'iframe:rolldown')) + + mesh.peer('iframe:rolldown').callEvent('rolldown:notify', { payload: 1 }) + + const relayEventCall = serverCalls.find(c => c.method === 'devtoolskit:internal:mesh:relay-event') + expect(relayEventCall).toBeDefined() + expect(relayEventCall?.kind).toBe('event') + expect(relayEventCall?.args[0]).toEqual({ + to: 'iframe:rolldown', + method: 'rolldown:notify', + args: [{ payload: 1 }], + }) + }) + + it('throws when target has no direct link and peer is the server itself', async () => { + // Server meshes should NOT attempt relay (they'd relay through themselves) + const serverMesh = new PeerMesh({ self: makePeer('devtools-server', 'devtools-server') }) + await expect( + serverMesh.peer('iframe:rolldown').call('some:fn'), + ).rejects.toThrow(/No link available/) + }) + + it('broadcast targets direct-linked peers matching the role pattern', async () => { + const { mesh } = buildMesh() + + const iframe1 = makePeer('p1', 'iframe:rolldown') + const iframe2 = makePeer('p2', 'iframe:vite') + const { rpc: rpc1, calls: calls1 } = makeFakeRpc() + const { rpc: rpc2, calls: calls2 } = makeFakeRpc() + mesh.attachLink(makeLink(iframe1, rpc1)) + mesh.attachLink(makeLink(iframe2, rpc2)) + + await mesh.broadcast({ + to: 'iframe:*', + method: 'theme:changed', + args: [{ theme: 'dark' }], + event: true, + }) + + expect(calls1.some(c => c.method === 'theme:changed')).toBe(true) + expect(calls2.some(c => c.method === 'theme:changed')).toBe(true) + }) +}) diff --git a/packages/rpc/src/peer/mesh.ts b/packages/rpc/src/peer/mesh.ts new file mode 100644 index 00000000..e54ad1e0 --- /dev/null +++ b/packages/rpc/src/peer/mesh.ts @@ -0,0 +1,268 @@ +import type { Link } from './link' +import type { + PeerDescriptor, + PeerHandle, + PeerId, + PeerMeshEvents, + PeerQuery, + PeerRole, + PeerRolePattern, + TransportAdapter, + TransportAdapterContext, +} from './types' +import { PeerDirectory } from './directory' +import { LinkTable } from './link' + +export interface PeerMeshOptions { + self: PeerDescriptor +} + +export interface BroadcastOptions { + to?: PeerId | PeerRole | PeerRolePattern | PeerQuery + method: string + args: Args + /** Don't wait for replies; fire-and-forget. */ + event?: boolean + /** Don't error if the target doesn't have the method. */ + optional?: boolean + /** Custom filter to exclude specific links after target resolution. */ + filter?: (link: Link) => boolean +} + +type Listeners = { + [K in keyof E]: Set +} + +/** + * The PeerMesh is the top-level object each process holds. It tracks the + * local peer's identity, the directory of known remote peers, and the set + * of currently-established links. + * + * Transport adapters are registered via {@link register}. Each adapter is + * responsible for establishing links; when a link is established the adapter + * calls {@link PeerMesh.attachLink} to register it in the mesh. + */ +export class PeerMesh { + readonly self: PeerDescriptor + readonly directory: PeerDirectory = new PeerDirectory() + readonly links: LinkTable = new LinkTable() + + private readonly adapters: Set = new Set() + private readonly adapterDisposers: Map void> = new Map() + private readonly listeners: Listeners = { + 'peer:connected': new Set(), + 'peer:disconnected': new Set(), + 'peer:updated': new Set(), + } + + constructor(options: PeerMeshOptions) { + this.self = options.self + this.directory.upsert(options.self) + } + + /** + * Register a transport adapter. Calls the adapter's `setup` with a context + * that exposes {@link attachLink} and a read-only directory view. + */ + async register(adapter: TransportAdapter, disposer?: () => void): Promise<() => void> { + this.adapters.add(adapter) + if (disposer) + this.adapterDisposers.set(adapter, disposer) + const context = this.createAdapterContext() + await adapter.setup?.(context) + return () => { + void adapter.dispose?.() + this.adapters.delete(adapter) + const dispose = this.adapterDisposers.get(adapter) + if (dispose) + dispose() + this.adapterDisposers.delete(adapter) + } + } + + private createAdapterContext(): TransportAdapterContext { + return { + self: this.self, + attachLink: link => this.attachLink(link), + directory: { + list: () => this.directory.list(), + get: id => this.directory.get(id), + }, + } + } + + /** + * List all registered adapters. + */ + getAdapters(): TransportAdapter[] { + return Array.from(this.adapters) + } + + /** + * Attach an established link to the mesh. Called by adapters after they + * establish a connection. Also upserts the remote peer in the directory. + */ + attachLink(link: Link): void { + this.directory.upsert(link.remote) + this.links.add(link) + this.emit('peer:connected', link.remote) + link.onClose(() => { + this.links.remove(link) + if (this.links.get(link.remote.id).length === 0) { + this.directory.remove(link.remote.id) + this.emit('peer:disconnected', link.remote.id) + } + }) + } + + /** + * Get a handle to call a specific peer. The target may be a peer id, a + * role, a role pattern, or a free-form query. + */ + peer(target: PeerId | PeerRole | PeerRolePattern | PeerQuery): PeerHandle { + return createPeerHandle(this, target) + } + + /** + * Return handles for every peer matching the query. + */ + findPeers(query: PeerQuery): PeerHandle[] { + return this.directory.query(query).map(peer => createPeerHandle(this, peer.id)) + } + + /** + * Send a call to every peer matching the target, filtered optionally. + */ + async broadcast(options: BroadcastOptions): Promise { + const { method, args, event = false, optional = true, filter } = options + const targets = options.to + ? this.directory.resolve(options.to).map(p => p.id) + : this.directory.list().filter(p => p.id !== this.self.id).map(p => p.id) + + await Promise.allSettled( + targets.flatMap((id) => { + const links = this.links.get(id) + const link = this.links.pick(id) + if (!link) + return [] + if (filter && !filter(link)) + return [] + void links + return [ + link.rpc.$callRaw({ + method, + args, + event, + optional, + }), + ] + }), + ) + } + + on(event: K, fn: PeerMeshEvents[K]): () => void { + this.listeners[event].add(fn) + return () => this.listeners[event].delete(fn) + } + + private emit(event: K, ...args: Parameters): void { + for (const fn of this.listeners[event]) + (fn as (...a: any[]) => void)(...args) + } +} + +const SERVER_PEER_ID = 'devtools-server' +const RELAY_METHOD = 'devtoolskit:internal:mesh:relay' +const RELAY_EVENT_METHOD = 'devtoolskit:internal:mesh:relay-event' + +function createPeerHandle(mesh: PeerMesh, target: PeerId | PeerRole | PeerRolePattern | PeerQuery): PeerHandle { + const resolveLink = (): Link | undefined => { + const peers = mesh.directory.resolve(target) + for (const peer of peers) { + const link = mesh.links.pick(peer.id) + if (link) + return link + } + return undefined + } + + const resolveDescriptor = (): PeerDescriptor => { + const peers = mesh.directory.resolve(target) + return peers[0] ?? { + id: typeof target === 'string' ? target : '', + role: (typeof target === 'string' ? target : 'unknown') as PeerRole, + capabilities: [], + meta: {}, + links: [], + } + } + + // Resolve the server link for relay fallback. Returns undefined when this + // peer IS the server (self.role === 'devtools-server'), since servers do + // not relay through themselves. + const resolveServerLink = (): Link | undefined => { + if (mesh.self.role === 'devtools-server') + return undefined + return mesh.links.pick(SERVER_PEER_ID) + } + + return { + get descriptor() { + return resolveDescriptor() + }, + get isDirect() { + const link = resolveLink() + return link?.isDirect ?? false + }, + async call(method, ...args) { + const link = resolveLink() + if (link) { + return await (link.rpc as any).$call(method, ...args) + } + const server = resolveServerLink() + if (!server) { + throw new Error(`[PeerMesh] No link available to target ${JSON.stringify(target)}`) + } + return await (server.rpc as any).$call(RELAY_METHOD, { + to: target, + method, + args, + }) + }, + callEvent(method, ...args) { + const link = resolveLink() + if (link) { + ;(link.rpc as any).$callEvent(method, ...args) + return + } + const server = resolveServerLink() + if (!server) { + return + } + ;(server.rpc as any).$callEvent(RELAY_EVENT_METHOD, { + to: target, + method, + args, + }) + }, + async callOptional(method, ...args) { + const link = resolveLink() + if (link) { + return await (link.rpc as any).$callOptional(method, ...args) + } + const server = resolveServerLink() + if (!server) + return undefined + try { + return await (server.rpc as any).$call(RELAY_METHOD, { + to: target, + method, + args, + }) + } + catch { + return undefined + } + }, + } as PeerHandle +} diff --git a/packages/rpc/src/peer/types.ts b/packages/rpc/src/peer/types.ts new file mode 100644 index 00000000..15ceea87 --- /dev/null +++ b/packages/rpc/src/peer/types.ts @@ -0,0 +1,199 @@ +import type { ChannelOptions } from 'birpc' + +/** + * Stable identifier for a peer — persists across reconnects. + */ +export type PeerId = string + +/** + * Well-known and plugin-defined peer roles. + * + * Well-known roles are built-in; plugin-defined roles use the `plugin:` prefix. + * Iframes, workers, and Nitro peers use their own prefixes so consumers can + * target them by role pattern (e.g. `iframe:*`). + */ +export type PeerRole + = | 'devtools-server' + | 'client:embedded' + | 'client:standalone' + | 'webext:devtools-panel' + | `iframe:${string}` + | `worker:${string}` + | `nitro:${string}` + | `plugin:${string}` + | (string & {}) + +/** + * Pattern matching syntax for role queries. `*` matches any suffix within a + * segment; e.g. `iframe:*` matches `iframe:rolldown` and `iframe:vite`. + */ +export type PeerRolePattern = PeerRole | `${string}:*` | '*' + +/** + * Transport kinds recognized by the mesh. + * + * New adapters can extend this with string literals via augmentation. + */ +export type TransportKind + = | 'ws' + | 'postmessage' + | 'broadcastchannel' + | 'http' + | 'in-process' + | 'message-channel' + | 'comlink-worker' + | (string & {}) + +/** + * Info about a transport link a peer advertises. + */ +export interface TransportLinkInfo { + transport: TransportKind + endpoint?: string + priority: number +} + +/** + * A peer in the mesh — a runtime context (node process, browser tab, iframe, + * worker, etc.) participating in RPC. + */ +export interface PeerDescriptor { + id: PeerId + role: PeerRole + capabilities: readonly string[] + meta: Record + links: readonly TransportLinkInfo[] +} + +/** + * A query used to find peer(s) by role / capability / meta. + */ +export interface PeerQuery { + role?: PeerRole | PeerRolePattern + capability?: string + meta?: Partial> +} + +/** + * Propagated authentication context. `originPeerId` and `originIsTrusted` + * describe the caller; `sig` is an HMAC produced by the devtools-server when + * a call is relayed through it (so the target can verify origin without + * trusting intermediaries). + */ +export interface AuthContext { + originPeerId: PeerId + originIsTrusted: boolean + sig?: string + method?: string +} + +/** + * Routing envelope. Every cross-peer RPC message in routing mode is wrapped + * in an envelope; direct 2-peer links may skip the envelope as an + * optimization. + */ +export interface Envelope { + v: 1 + from: PeerId + to: PeerId | PeerRole | PeerRolePattern + hops: PeerId[] + maxHops: number + corr?: string + auth: AuthContext + kind: 'call' | 'event' | 'reply' | 'error' | 'hello' | 'bye' | 'directory-delta' + payload: unknown +} + +/** + * The handshake frame a peer sends when establishing a link. + */ +export interface HelloFrame { + v: 1 + self: PeerDescriptor + authToken?: string +} + +/** + * Lifecycle of a link established by a transport adapter. + */ +export interface LinkChannel { + channel: ChannelOptions + close: () => void + meta?: Record +} + +/** + * Argument passed to a transport adapter when establishing a link. + */ +export interface TransportConnectArgs { + remote: PeerDescriptor + signal?: AbortSignal +} + +/** + * A transport adapter — the pluggable unit that establishes one link between + * two peers. + * + * The mesh calls {@link setup} once after registration; the adapter then + * creates links and attaches them to the mesh. Adapters may also be invited + * to establish a new link on-demand via {@link connect}. + */ +export interface TransportAdapter { + readonly kind: TransportKind + /** + * Called once when the adapter is registered with a mesh. + * + * The adapter typically spins up its infrastructure here (e.g. WS server, + * postMessage listener) and attaches links to the mesh as they arrive. + */ + setup?: (ctx: TransportAdapterContext) => void | Promise + /** + * Called when the adapter is disposed. + */ + dispose?: () => void | Promise + /** + * Optionally implemented: initiate a new link to a specific remote. + */ + connect?: (args: TransportConnectArgs) => Promise + /** + * Whether this adapter can serve the given peer pair. + */ + canServe?: (local: PeerDescriptor, remote: PeerDescriptor) => boolean +} + +/** + * Context passed to an adapter's {@link TransportAdapter.setup}. + * + * Defined here as a `Record` to avoid a circular dependency with + * `mesh.ts` / `link.ts`; concretely it is a {@link PeerMesh} with + * {@link PeerMesh.attachLink} exposed. + */ +export interface TransportAdapterContext { + self: PeerDescriptor + attachLink: (link: import('./link').Link) => void + directory: { + list: () => PeerDescriptor[] + get: (id: PeerId) => PeerDescriptor | undefined + } +} + +/** + * Events emitted by the mesh. + */ +export interface PeerMeshEvents { + 'peer:connected': (peer: PeerDescriptor) => void + 'peer:disconnected': (id: PeerId) => void + 'peer:updated': (peer: PeerDescriptor) => void +} + +/** + * A consumer-facing handle to a peer. Calls made through the handle are + * routed to the target peer via the best available link. + */ +export interface PeerHandle any> = Record any>> { + readonly descriptor: PeerDescriptor + readonly isDirect: boolean + call: (method: M, ...args: Parameters) => Promise>> + callEvent: (method: M, ...args: Parameters) => void + callOptional: (method: M, ...args: Parameters) => Promise> | undefined> +} diff --git a/packages/rpc/src/presets/ws/server.ts b/packages/rpc/src/presets/ws/server.ts index 94933122..8e2533a1 100644 --- a/packages/rpc/src/presets/ws/server.ts +++ b/packages/rpc/src/presets/ws/server.ts @@ -14,6 +14,10 @@ export interface DevToolsNodeRpcSessionMeta { clientAuthToken?: string isTrusted?: boolean subscribedStates: Set + /** Stable peer id this session has been assigned in the peer mesh. */ + peerId?: string + /** Peer role this session announced via `devtoolskit:internal:peer:announce`. */ + peerRole?: string } export interface WebSocketRpcServerOptions { diff --git a/packages/rpc/tsdown.config.ts b/packages/rpc/tsdown.config.ts index d7605103..59ed1d81 100644 --- a/packages/rpc/tsdown.config.ts +++ b/packages/rpc/tsdown.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ 'presets/ws/client': 'src/presets/ws/client.ts', 'presets/ws/server': 'src/presets/ws/server.ts', 'presets/index': 'src/presets/index.ts', + 'peer/index': 'src/peer/index.ts', + 'peer/adapters/ws-client': 'src/peer/adapters/ws-client.ts', + 'peer/adapters/ws-server': 'src/peer/adapters/ws-server.ts', }, tsconfig: '../../tsconfig.base.json', clean: true, diff --git a/test/exports/@vitejs/devtools-rpc.yaml b/test/exports/@vitejs/devtools-rpc.yaml index 841632fb..2b453b46 100644 --- a/test/exports/@vitejs/devtools-rpc.yaml +++ b/test/exports/@vitejs/devtools-rpc.yaml @@ -1,17 +1,34 @@ .: createClientFromDump: function createDefineWrapperWithContext: function + createLink: function defineRpcFunction: function dumpFunctions: function getDefinitionsWithDumps: function getRpcHandler: function getRpcResolvedSetupResult: function + LinkTable: function + matchPeer: function + matchRolePattern: function + PeerDirectory: function + PeerMesh: function RpcCacheManager: function RpcFunctionsCollectorBase: function validateDefinition: function validateDefinitions: function ./client: createRpcClient: function +./peer: + createLink: function + LinkTable: function + matchPeer: function + matchRolePattern: function + PeerDirectory: function + PeerMesh: function +./peer/adapters/ws-client: + createWsClientAdapter: function +./peer/adapters/ws-server: + createWsServerAdapter: function ./presets: defineRpcClientPreset: function defineRpcServerPreset: function diff --git a/tsconfig.base.json b/tsconfig.base.json index 497daf41..d3cb3c9a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,6 +9,15 @@ "module": "esnext", "moduleResolution": "Bundler", "paths": { + "@vitejs/devtools-rpc/peer/adapters/ws-server": [ + "./packages/rpc/src/peer/adapters/ws-server.ts" + ], + "@vitejs/devtools-rpc/peer/adapters/ws-client": [ + "./packages/rpc/src/peer/adapters/ws-client.ts" + ], + "@vitejs/devtools-rpc/peer": [ + "./packages/rpc/src/peer/index.ts" + ], "@vitejs/devtools-rpc/presets/ws/server": [ "./packages/rpc/src/presets/ws/server.ts" ],