From 26f653c01d1f897b33840044b135ec6305dd03f8 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 7 May 2026 15:18:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(devframe):=20streaming=20channel=20API?= =?UTF-8?q?=20for=20server=E2=86=94client=20chunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ctx.rpc.streaming` (server) and `rpc.streaming` (client) — a first-class streaming primitive built on the existing WS/birpc transport with full Web Streams interop, replay-on-reconnect, cooperative cancellation, and bidirectional support. Server-to-client (closes vitejs/devtools#306): - `channel.start({ id? })` returns a sink with imperative `write` / `close` / `error` plus a `WritableStream` for `pipeTo`. - `channel.pipeFrom(readable)` shorthand for one-call consumption. - `rpc.streaming.subscribe(channel, id)` returns a reader that's both `AsyncIterable` and exposes `readable: ReadableStream`. - `replayWindow` keeps a per-stream ring buffer; late or reconnecting subscribers replay from `afterSeq`. `closedStreamRetention` (default 30 s when replay is on) holds finished streams briefly. Client-to-server uploads: - `channel.openInbound({ id? })` returns a server-side reader; pair with a normal RPC action that returns the id. - `rpc.streaming.upload(channel, id)` returns a sink whose chunks / end / error forward over the wire; signal aborts on server cancel. - Each inbound is owned by one session — no fan-in, no shared state; client disconnect surfaces as `UploadDisconnected` to the consumer. Internal surface: - `utils/streaming-channel.ts` — framework-neutral `createStreamSink` / `createStreamReader` primitives. - `node/rpc-streaming.ts` and `client/rpc-streaming.ts` — RPC hosts. - Five wire methods under `devtoolskit:internal:streaming:*`. - Adds `_emitSessionDisconnected` on `RpcFunctionsHost` so adapters can clean up per-session subscribers on WS close. Wired in both `devframe/server.ts` and `packages/core/src/node/ws.ts`. Also wires the async-storage resolver in devframe's standalone server so `getCurrentRpcSession()` works there (was a latent gap). - Four new structured diagnostics: DF0029 (overflow), DF0030 (unknown id), DF0031 (write after close), DF0032 (channel name collision) with docs pages and sidebar entries. Migrations & dogfooding: - `DevToolsTerminalHost` now pipes session output into a streaming channel (`devtoolskit:internal:terminals`); the legacy `terminal:session:stream-chunk` host event and its rebroadcast are removed. Client xterm renderer consumes via `for await`. - New `devframe-streaming-chat` example — Preact UI driving a synthetic LLM token producer, end-to-end tests covering happy path, cancel, fan-out, replay. - Docs: new `guide/streaming.md`, updated RPC and terminals guides. Co-Authored-By: Claude Opus 4.7 (1M context) --- alias.ts | 1 + devframe/docs/.vitepress/sidebar.ts | 3 +- devframe/docs/errors/DF0029.md | 27 + devframe/docs/errors/DF0030.md | 25 + devframe/docs/errors/DF0031.md | 38 ++ devframe/docs/errors/DF0032.md | 24 + devframe/docs/errors/index.md | 4 + devframe/docs/guide/rpc.md | 14 + devframe/docs/guide/streaming.md | 240 +++++++ devframe/docs/guide/terminals.md | 14 +- .../devframe-streaming-chat/README.md | 34 + .../examples/devframe-streaming-chat/bin.mjs | 14 + .../devframe-streaming-chat/package.json | 29 + .../src/client/app.tsx | 142 +++++ .../src/client/index.html | 37 ++ .../src/client/main.tsx | 7 + .../src/client/vite.config.ts | 15 + .../devframe-streaming-chat/src/devtool.ts | 115 ++++ .../devframe-streaming-chat/tests/_utils.ts | 76 +++ .../tests/streaming-chat.test.ts | 168 +++++ .../devframe-streaming-chat/tsconfig.json | 14 + devframe/packages/devframe/package.json | 1 + .../packages/devframe/src/client/index.ts | 1 + .../devframe/src/client/rpc-streaming.ts | 192 ++++++ devframe/packages/devframe/src/client/rpc.ts | 10 + .../src/node/__tests__/rpc-streaming.test.ts | 369 +++++++++++ .../packages/devframe/src/node/context.ts | 7 - .../packages/devframe/src/node/diagnostics.ts | 21 + .../devframe/src/node/host-functions.ts | 16 +- .../devframe/src/node/host-terminals.ts | 61 +- devframe/packages/devframe/src/node/index.ts | 1 + .../devframe/src/node/rpc-streaming.ts | 378 +++++++++++ devframe/packages/devframe/src/node/server.ts | 35 +- .../devframe/src/rpc/transports/ws-server.ts | 12 + .../devframe/src/types/rpc-augments.ts | 62 +- devframe/packages/devframe/src/types/rpc.ts | 109 ++++ .../packages/devframe/src/types/terminals.ts | 7 - .../src/utils/streaming-channel.test.ts | 236 +++++++ .../devframe/src/utils/streaming-channel.ts | 390 ++++++++++++ devframe/packages/devframe/tsdown.config.ts | 1 + .../tsnapi/devframe/client.snapshot.d.ts | 9 + .../tsnapi/devframe/client.snapshot.js | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 4 +- .../tsnapi/devframe/node.snapshot.d.ts | 5 + .../tsnapi/devframe/node.snapshot.js | 1 + .../tsnapi/devframe/types.snapshot.d.ts | 4 +- .../utils/streaming-channel.snapshot.d.ts | 14 + .../utils/streaming-channel.snapshot.js | 7 + .../client/webcomponents/state/terminals.ts | 42 +- packages/core/src/node/rpc/index.ts | 3 +- packages/core/src/node/ws.ts | 1 + packages/kit/src/types/index.ts | 4 +- pnpm-lock.yaml | 590 +++++++++++++++--- .../@vitejs/devtools-kit/index.snapshot.d.ts | 4 +- tsconfig.base.json | 3 + turbo.json | 7 + vitest.config.ts | 1 + 57 files changed, 3502 insertions(+), 148 deletions(-) create mode 100644 devframe/docs/errors/DF0029.md create mode 100644 devframe/docs/errors/DF0030.md create mode 100644 devframe/docs/errors/DF0031.md create mode 100644 devframe/docs/errors/DF0032.md create mode 100644 devframe/docs/guide/streaming.md create mode 100644 devframe/examples/devframe-streaming-chat/README.md create mode 100755 devframe/examples/devframe-streaming-chat/bin.mjs create mode 100644 devframe/examples/devframe-streaming-chat/package.json create mode 100644 devframe/examples/devframe-streaming-chat/src/client/app.tsx create mode 100644 devframe/examples/devframe-streaming-chat/src/client/index.html create mode 100644 devframe/examples/devframe-streaming-chat/src/client/main.tsx create mode 100644 devframe/examples/devframe-streaming-chat/src/client/vite.config.ts create mode 100644 devframe/examples/devframe-streaming-chat/src/devtool.ts create mode 100644 devframe/examples/devframe-streaming-chat/tests/_utils.ts create mode 100644 devframe/examples/devframe-streaming-chat/tests/streaming-chat.test.ts create mode 100644 devframe/examples/devframe-streaming-chat/tsconfig.json create mode 100644 devframe/packages/devframe/src/client/rpc-streaming.ts create mode 100644 devframe/packages/devframe/src/node/__tests__/rpc-streaming.test.ts create mode 100644 devframe/packages/devframe/src/node/rpc-streaming.ts create mode 100644 devframe/packages/devframe/src/utils/streaming-channel.test.ts create mode 100644 devframe/packages/devframe/src/utils/streaming-channel.ts create mode 100644 devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts create mode 100644 devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js diff --git a/alias.ts b/alias.ts index 7ee1b8be..be8c05ef 100644 --- a/alias.ts +++ b/alias.ts @@ -21,6 +21,7 @@ export const alias = { 'devframe/utils/promise': df('devframe/src/utils/promise.ts'), 'devframe/utils/shared-state': df('devframe/src/utils/shared-state.ts'), 'devframe/utils/state': df('devframe/src/utils/state.ts'), + 'devframe/utils/streaming-channel': df('devframe/src/utils/streaming-channel.ts'), 'devframe/utils/when': df('devframe/src/utils/when.ts'), 'devframe/adapters/cli': df('devframe/src/adapters/cli.ts'), 'devframe/adapters/dev': df('devframe/src/adapters/dev.ts'), diff --git a/devframe/docs/.vitepress/sidebar.ts b/devframe/docs/.vitepress/sidebar.ts index c8547530..93455e92 100644 --- a/devframe/docs/.vitepress/sidebar.ts +++ b/devframe/docs/.vitepress/sidebar.ts @@ -10,6 +10,7 @@ export default function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { text: 'Adapters', link: `${prefix}/guide/adapters` }, { text: 'RPC', link: `${prefix}/guide/rpc` }, { text: 'Shared State', link: `${prefix}/guide/shared-state` }, + { text: 'Streaming', link: `${prefix}/guide/streaming` }, { text: 'Dock System', link: `${prefix}/guide/dock-system` }, { text: 'Commands', link: `${prefix}/guide/commands` }, { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, @@ -26,7 +27,7 @@ export default function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] text: 'Error Reference', link: `${prefix}/errors/`, collapsed: true, - items: Array.from({ length: 28 }, (_, i) => { + items: Array.from({ length: 32 }, (_, i) => { const code = `DF${String(i + 1).padStart(4, '0')}` return { text: code, link: `${prefix}/errors/${code}` } }), diff --git a/devframe/docs/errors/DF0029.md b/devframe/docs/errors/DF0029.md new file mode 100644 index 00000000..2d5f717c --- /dev/null +++ b/devframe/docs/errors/DF0029.md @@ -0,0 +1,27 @@ +--- +outline: deep +--- + +# DF0029: Stream Buffer Overflow + +> Package: `devframe` + +## Message + +> Stream "`{channel}#{id}`" dropped `{dropped}` chunk(s) after exceeding the client high-water mark. + +## Cause + +A streaming subscriber's queue grew past its `highWaterMark` because the consumer is slower than the producer. The oldest chunks were dropped to keep memory bounded. + +This is a soft warning — the stream keeps running and remaining chunks still flow. + +## Fix + +- Raise `highWaterMark` on `rpc.streaming.subscribe(channel, id, { highWaterMark })` if the consumer can occasionally catch up. +- Slow the producer so it doesn't outpace the wire (e.g. throttle, debounce, or batch chunks server-side). +- Switch to `sharedState` if you only need the latest value rather than every intermediate chunk. + +## Source + +`packages/devframe/src/client/rpc-streaming.ts` diff --git a/devframe/docs/errors/DF0030.md b/devframe/docs/errors/DF0030.md new file mode 100644 index 00000000..3d7e00b4 --- /dev/null +++ b/devframe/docs/errors/DF0030.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0030: Unknown Stream ID + +> Package: `devframe` + +## Message + +> Stream "`{channel}#{id}`" is unknown — no producer has called `channel.start({ id: "{id}" })`. + +## Cause + +A client subscribed to a stream id that the server-side channel doesn't know about. Either the producer never started a stream with that id, the producer already ended it and `replayWindow` is `0`, or the client passed the wrong id. + +## Fix + +- Make sure the action that returns the stream id runs **before** the client subscribes — typically by awaiting `rpc.call('your-action')` and using the returned id. +- Bump `replayWindow` on `ctx.rpc.streaming.create(name, { replayWindow })` if you need clients to resume after the producer has finished but kept the buffer warm. +- Check the id is propagated correctly across boundaries (action return value → component prop → subscribe call). + +## Source + +`packages/devframe/src/node/rpc-streaming.ts` diff --git a/devframe/docs/errors/DF0031.md b/devframe/docs/errors/DF0031.md new file mode 100644 index 00000000..b62bd2cc --- /dev/null +++ b/devframe/docs/errors/DF0031.md @@ -0,0 +1,38 @@ +--- +outline: deep +--- + +# DF0031: Write to Closed Stream + +> Package: `devframe` + +## Message + +> Cannot write to closed stream "`{channel}#{id}`". + +## Cause + +`stream.write(chunk)` was called after the stream was closed via `stream.close()` / `stream.error()` or after the consumer cancelled (which aborts `stream.signal`). + +## Fix + +Producers should poll `stream.signal.aborted` and exit cleanly: + +```ts +const stream = channel.start({ id }) +try { + for (const chunk of source) { + if (stream.signal.aborted) + return + stream.write(chunk) + } + stream.close() +} +catch (err) { + stream.error(err) +} +``` + +## Source + +`packages/devframe/src/utils/streaming-channel.ts` diff --git a/devframe/docs/errors/DF0032.md b/devframe/docs/errors/DF0032.md new file mode 100644 index 00000000..09197324 --- /dev/null +++ b/devframe/docs/errors/DF0032.md @@ -0,0 +1,24 @@ +--- +outline: deep +--- + +# DF0032: Streaming Channel Already Registered + +> Package: `devframe` + +## Message + +> Streaming channel "`{channel}`" is already registered. + +## Cause + +Two calls to `ctx.rpc.streaming.create(name, ...)` used the same channel name. Each name owns a wire namespace and must be unique within a context. + +## Fix + +- Reuse the existing channel handle rather than creating a new one with the same name. +- If two devtools want isolated streams, give each a distinct namespaced name (`my-devtool:chat-stream`, `other-devtool:logs-stream`). + +## Source + +`packages/devframe/src/node/rpc-streaming.ts` diff --git a/devframe/docs/errors/index.md b/devframe/docs/errors/index.md index ba63f39a..123fe745 100644 --- a/devframe/docs/errors/index.md +++ b/devframe/docs/errors/index.md @@ -46,3 +46,7 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface. | [DF0026](./DF0026) | error | No Dump Match | DTK0006 | | [DF0027](./DF0027) | error | Invalid Dump Configuration | DTK0007 | | [DF0028](./DF0028) | error | Snapshot Type Mismatch | DTK0008 | +| [DF0029](./DF0029) | warn | Stream Buffer Overflow | — | +| [DF0030](./DF0030) | error | Unknown Stream ID | — | +| [DF0031](./DF0031) | error | Write to Closed Stream | — | +| [DF0032](./DF0032) | error | Streaming Channel Already Registered | — | diff --git a/devframe/docs/guide/rpc.md b/devframe/docs/guide/rpc.md index 8434b51e..bcd33ba5 100644 --- a/devframe/docs/guide/rpc.md +++ b/devframe/docs/guide/rpc.md @@ -144,6 +144,20 @@ defineDevtool({ | `event` | `boolean` | Fire-and-forget (don't wait for responses). | | `filter` | `(client) => boolean` | Skip specific clients. | +## Streaming + +For chunk-style server→client feeds (chat deltas, log lines, build progress), reach for [streaming channels](./streaming) instead of hand-rolling `action + delta/end events`. The streaming API gives you stream IDs, cancellation, replay, and Web Streams interop for free: + +```ts +const channel = ctx.rpc.streaming.create('my-devtool:chat', { + replayWindow: 256, +}) +const stream = channel.start() +sourceReadable.pipeTo(stream.writable) +``` + +See the [Streaming guide](./streaming) for the full API. + ## Local Invocation `ctx.rpc.invokeLocal` calls a registered server function directly without going through a transport — useful for cross-function composition on the server side: diff --git a/devframe/docs/guide/streaming.md b/devframe/docs/guide/streaming.md new file mode 100644 index 00000000..7941d155 --- /dev/null +++ b/devframe/docs/guide/streaming.md @@ -0,0 +1,240 @@ +--- +outline: deep +--- + +# Streaming + +Devframe's streaming-channel API provides server→client push for chunk-style data — chat deltas, log lines, build progress, anything you'd otherwise express as a sequence of fire-and-forget events. It builds on the same WebSocket transport as the rest of the RPC layer, but adds the conventions every chunked feed needs: stream IDs, cooperative cancellation, replay on reconnect, and first-class **Web Streams** interop. + +## Overview + +```mermaid +sequenceDiagram + participant Producer as Producer (server) + participant Channel as ctx.rpc.streaming
channel + participant Browser as Subscriber (browser) + + Producer->>Channel: start({ id }) + Channel-->>Browser: chunk(seq=1, "...") + Channel-->>Browser: chunk(seq=2, "...") + Producer->>Channel: close() + Channel-->>Browser: end() +``` + +A **channel** owns a wire namespace. Each call to `channel.start()` produces an individual **stream** keyed by an id (auto-generated unless you pass one). Subscribers join by `(channelName, id)`. + +## Defining a Channel + +In your `setup`, create the channel once. Channels are framework-neutral, so the same code works for `cli`, `vite`, `kit`, and `embedded` adapters: + +```ts +import { defineDevtool, defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +export default defineDevtool({ + id: 'my-devtool', + name: 'My Devtool', + async setup(ctx) { + const channel = ctx.rpc.streaming.create('my-devtool:chat', { + replayWindow: 256, + }) + + ctx.rpc.register(defineRpcFunction({ + name: 'my-devtool:start-chat', + type: 'action', + jsonSerializable: true, + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string() }), + handler: async ({ prompt }) => { + const stream = channel.start() + ;(async () => { + for await (const token of fakeLLM(prompt, { signal: stream.signal })) { + stream.write(token) + } + stream.close() + })() + return { streamId: stream.id } + }, + })) + }, +}) +``` + +The channel name follows the same `:` convention as RPC functions. + +## Producing — Three Surfaces, One Stream + +The handle returned by `channel.start({ id? })` is both an imperative producer and a Web Streams `WritableStream`: + +```ts +const stream = channel.start({ id: 'optional-explicit-id' }) + +// Imperative — minimal, hand-rolled producers +stream.write(chunk) +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel +stream.id // string — what clients subscribe to + +// Web Streams — pipe any ReadableStream in: +sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) + +// Convenience — start + pipe in one call: +const stream = await channel.pipeFrom(sourceReadable) +``` + +Producers should poll `stream.signal.aborted` and exit cooperatively when it flips: + +```ts +for (const token of source) { + if (stream.signal.aborted) + return + stream.write(token) +} +stream.close() +``` + +### Node.js Stream Interop + +Web Streams are the canonical surface, but Node 17+ ships free converters that bridge to `node:stream`: + +```ts +import { Readable, Writable } from 'node:stream' + +// Pipe a Node Readable into the streaming channel +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) + +// Pipe the channel out to a Node Writable +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +Devframe doesn't wrap these — they're standard library, and the surface stays small. + +## Consuming — `for await` or `pipeTo` + +The client returns a reader that's both an `AsyncIterable` and exposes a `ReadableStream`: + +```ts +import { connectDevtool } from 'devframe/client' + +const rpc = await connectDevtool() +const { streamId } = await rpc.call('my-devtool:start-chat', { + prompt: 'Hello', +}) + +const reader = rpc.streaming.subscribe('my-devtool:chat', streamId) + +// Async iterable — the simplest consumer pattern +for await (const token of reader) + appendToken(token) + +// Or pipe to a DOM-side WritableStream +await reader.readable.pipeTo(downloadWritable) + +reader.cancel() // sends cancel upstream; server stream.signal flips +``` + +Pick one surface per reader — they share a single internal queue, so concurrent draining will race. + +## Lifecycle and Cancellation + +| Event | Server side | Client side | +|-------|-------------|-------------| +| Producer calls `stream.close()` / `stream.error(err)` | Broadcasts `end` to subscribers | `for await` resolves (success) or throws (error) | +| Consumer calls `reader.cancel()` | Server's `stream.signal` aborts when the **last** subscriber cancels — handlers should poll and exit | Reader marks itself cancelled; `for await` ends without iterating | +| WS disconnects | When the **last** subscriber drops, server aborts `stream.signal` | Reader stays alive; resubscribes automatically when trust is re-established | +| `chat` panel closes mid-stream | Reader cancel cascades upstream | — | + +A stream with multiple subscribers stays alive until the last one cancels or disconnects. Producers should always make `stream.signal.aborted` part of their inner loop. + +## Client-to-Server Uploads + +The same channel works in reverse for chunk-style uploads — file content, mic / screen-share frames, browser-side logs forwarded to disk, anything you'd otherwise hand-roll as `multipart` over HTTP. The pattern uses one normal RPC call to allocate the id, then dedicated streaming events for the chunks: + +```ts +// Server — typically inside an action handler +ctx.rpc.register(defineRpcFunction({ + name: 'my-devtool:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + + // Process chunks asynchronously — the action returns immediately + // so the client can start uploading. + ;(async () => { + const file = createWriteStream(name) + for await (const chunk of reader) + file.write(chunk) + file.close() + })() + + return { uploadId: reader.id } + }, +})) +``` + +```ts +// Client +const { uploadId } = await rpc.call('my-devtool:upload-file', { + name: 'capture.bin', +}) +const upload = rpc.streaming.upload('my-devtool:files', uploadId) + +// Imperative +upload.write(chunk1) +upload.write(chunk2) +upload.close() + +// Or pipe a Web ReadableStream straight in: +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Lifecycle mirrors the outbound case: + +- `upload.signal` aborts when the **server** calls `reader.cancel()` (the server cancellation broadcasts an `upload-cancel` to the uploading session). +- `upload.error(err)` propagates as a thrown error inside the server's `for await`. +- If the client disconnects mid-upload, the server's `for await` exits with an `UploadDisconnected` error so consumers can clean up. + +Each `openInbound()` allocates a fresh server-allocated id and is owned by exactly one uploading session — there's no fan-in, no shared subscribers, and no replay (the producer is the client, so reconnect means restart). + +## Replay on Reconnect + +When the channel is created with `replayWindow: N`, the server keeps a rolling buffer of the last `N` chunks per stream. On (re)subscribe, the client passes the highest sequence number it has seen, and the server replays anything newer before resuming live. + +```ts +ctx.rpc.streaming.create('my-devtool:chat', { + replayWindow: 256, // chunks to retain per stream id + closedStreamRetention: 30_000, // ms to hold closed streams for late subscribers +}) +``` + +`closedStreamRetention` defaults to 30 seconds when `replayWindow > 0` (so a panel re-opened a few seconds after a chat finishes still gets the full transcript), or 0 when replay is disabled. Set it explicitly to opt in or out. + +## Backpressure + +The client maintains a bounded queue per subscription (`highWaterMark`, default 256). When the consumer can't keep up, the **oldest** queued chunk is dropped and a [`DF0029`](../errors/DF0029) warning is logged. This is best-effort — proper transport-level backpressure isn't worth threading through birpc for the streaming use cases that exist today. + +```ts +const reader = rpc.streaming.subscribe('my-devtool:chat', id, { + highWaterMark: 1024, // raise if you expect bursts the consumer can recover from +}) +``` + +If you need authoritative state rather than every intermediate value, prefer [shared state](./shared-state) — it carries Immer patches with delivery guarantees, at the cost of being structured rather than streaming. + +## When to Use Streaming vs Events vs Shared State + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds (LLM deltas, build logs) | Notifications without payload (`refresh`, `clear`) | Long-lived UI state (selections, panel layout) | +| Per-call lifecycles with cancellation | Cross-cutting signals broadcast to all clients | Reactive snapshots that survive reconnect | +| Replay on reconnect | Fire-and-forget signaling | Diff-based sync between clients | +| Client-to-server uploads (files, mic frames) | | | + +## Reference + +- API surface: `RpcStreamingHost`, `RpcStreamingChannel`, `StreamSink`, `StreamReader` in `devframe/types`. +- Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). +- Errors: [`DF0029`](../errors/DF0029) (overflow), [`DF0030`](../errors/DF0030) (unknown stream id), [`DF0031`](../errors/DF0031) (write to closed stream), [`DF0032`](../errors/DF0032) (channel name collision). diff --git a/devframe/docs/guide/terminals.md b/devframe/docs/guide/terminals.md index a5326115..f8d931ae 100644 --- a/devframe/docs/guide/terminals.md +++ b/devframe/docs/guide/terminals.md @@ -102,13 +102,19 @@ ctx.docks.register({ ctx.terminals.events.on('terminal:session:updated', (session) => { console.log(session.id, session.status) }) +``` -ctx.terminals.events.on('terminal:session:stream-chunk', ({ id, chunks, ts }) => { - // chunks: string[] delivered together, timestamped ms -}) +Output chunks aren't delivered as host events anymore — terminals now use the [streaming](./streaming) channel `devtoolskit:internal:terminals` keyed by session id. From the browser: + +```ts +const reader = rpc.streaming.subscribe( + 'devtoolskit:internal:terminals', + sessionId, +) +for await (const chunk of reader) writeToTerminal(chunk) ``` -Stream chunks arrive in batches (the host coalesces rapid output), so each event may contain multiple lines. +A server-side bridge inside `DevToolsTerminalHost` pipes each session's `ReadableStream` straight into the channel — you don't need to wire anything yourself unless you want a custom client-side renderer. ## Inspection diff --git a/devframe/examples/devframe-streaming-chat/README.md b/devframe/examples/devframe-streaming-chat/README.md new file mode 100644 index 00000000..2bf51c4a --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/README.md @@ -0,0 +1,34 @@ +# devframe-streaming-chat + +End-to-end demo of devframe's streaming-channel API. Mirrors the AI-deltas +use case from [vitejs/devtools#306](https://github.com/vitejs/devtools/issues/306): +the server emits synthesized "tokens" one at a time, and the browser renders +them incrementally with `for await`. + +## What it shows + +- `ctx.rpc.streaming.create(name, opts)` registers a streaming channel. +- An `action` RPC starts a stream and returns its `streamId`. +- The client calls `rpc.streaming.subscribe(name, id)` and consumes via + `for await (const token of reader)`. +- `reader.cancel()` aborts the server-side `stream.signal` so the producer + exits cleanly mid-stream. +- Reopening the panel mid-stream replays buffered tokens (replayWindow: 256). + +## Run it + +```sh +pnpm -C devframe/examples/devframe-streaming-chat run build +pnpm -C devframe/examples/devframe-streaming-chat run dev +``` + +Then open http://localhost:9897/ and pick a demo prompt, or type your own. + +## Run the tests + +```sh +pnpm -C devframe/examples/devframe-streaming-chat run test +``` + +Tests boot the server in-process and exercise the full WS round-trip: +happy path, cancellation, fan-out across two clients, and replay-after-resubscribe. diff --git a/devframe/examples/devframe-streaming-chat/bin.mjs b/devframe/examples/devframe-streaming-chat/bin.mjs new file mode 100755 index 00000000..c52486df --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import devtool from './src/devtool.ts' + +async function main() { + const cli = createCli(devtool) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/devframe/examples/devframe-streaming-chat/package.json b/devframe/examples/devframe-streaming-chat/package.json new file mode 100644 index 00000000..debc57eb --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/package.json @@ -0,0 +1,29 @@ +{ + "name": "devframe-streaming-chat-example", + "type": "module", + "version": "0.1.18", + "private": true, + "description": "End-to-end devframe demo — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`. Mirrors the AI-deltas use case from issue #306.", + "main": "src/devtool.ts", + "bin": { + "devframe-streaming-chat": "./bin.mjs" + }, + "scripts": { + "build": "vite build --config src/client/vite.config.ts", + "dev": "node bin.mjs", + "test": "vitest run" + }, + "dependencies": { + "devframe": "workspace:*", + "preact": "catalog:frontend" + }, + "devDependencies": { + "@preact/preset-vite": "catalog:build", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "sirv": "catalog:deps", + "vite": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/devframe/examples/devframe-streaming-chat/src/client/app.tsx b/devframe/examples/devframe-streaming-chat/src/client/app.tsx new file mode 100644 index 00000000..48dd93ad --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/src/client/app.tsx @@ -0,0 +1,142 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import type { StreamReader } from 'devframe/utils/streaming-channel' +import { connectDevtool } from 'devframe/client' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' + +const CHANNEL_NAME = 'devframe-streaming-chat:tokens' + +export function App() { + const [rpc, setRpc] = useState(null) + const [demoPrompts, setDemoPrompts] = useState([]) + const [prompt, setPrompt] = useState('Tell me about devframe.') + const [output, setOutput] = useState('') + const [streaming, setStreaming] = useState(false) + const [error, setError] = useState(null) + const readerRef = useRef | null>(null) + + useEffect(() => { + let cancelled = false + connectDevtool().then(async (r) => { + if (cancelled) + return + setRpc(r) + try { + const { prompts } = await r.call( + 'devframe-streaming-chat:demo-prompts' as any, + ) as { prompts: string[] } + if (!cancelled) + setDemoPrompts(prompts) + } + catch { + // demo prompts are optional + } + }) + return () => { + cancelled = true + readerRef.current?.cancel() + } + }, []) + + const start = useCallback(async (text: string) => { + if (!rpc || streaming || !text.trim()) + return + setError(null) + setOutput('') + setStreaming(true) + try { + const { streamId } = await rpc.call( + 'devframe-streaming-chat:start' as any, + { prompt: text }, + ) as { streamId: string } + + const reader = rpc.streaming.subscribe(CHANNEL_NAME, streamId) + readerRef.current = reader + try { + for await (const token of reader) + setOutput(prev => prev + token) + } + finally { + readerRef.current = null + } + } + catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + finally { + setStreaming(false) + } + }, [rpc, streaming]) + + const cancel = useCallback(() => { + readerRef.current?.cancel() + }, []) + + if (!rpc) + return

Connecting to devtool…

+ + return ( +
+
+

Streaming Chat

+ + A devframe streaming-API demo. Server emits one token every ~40 ms; + client renders incrementally via + {' '} + for await + . + +
+ +
{ + e.preventDefault() + start(prompt) + }} + > + setPrompt((e.target as HTMLInputElement).value)} + placeholder="Ask anything…" + disabled={streaming} + /> + {!streaming + ? + : } +
+ + {demoPrompts.length > 0 && !streaming && ( +
+ try: + {demoPrompts.map(p => ( + + ))} +
+ )} + +
{output}
+ +
+ backend: + {' '} + {rpc.connectionMeta.backend} + {error && ( + + {' '} + · error: + {error} + + )} +
+
+ ) +} diff --git a/devframe/examples/devframe-streaming-chat/src/client/index.html b/devframe/examples/devframe-streaming-chat/src/client/index.html new file mode 100644 index 00000000..8630157c --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/src/client/index.html @@ -0,0 +1,37 @@ + + + + + + + Streaming Chat + + + +
+ + + diff --git a/devframe/examples/devframe-streaming-chat/src/client/main.tsx b/devframe/examples/devframe-streaming-chat/src/client/main.tsx new file mode 100644 index 00000000..88207afa --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/src/client/main.tsx @@ -0,0 +1,7 @@ +import { render } from 'preact' +import { App } from './app' + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') +render(, root) diff --git a/devframe/examples/devframe-streaming-chat/src/client/vite.config.ts b/devframe/examples/devframe-streaming-chat/src/client/vite.config.ts new file mode 100644 index 00000000..5f0082aa --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/src/client/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url' +import preact from '@preact/preset-vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../../alias' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [preact()], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/devframe/examples/devframe-streaming-chat/src/devtool.ts b/devframe/examples/devframe-streaming-chat/src/devtool.ts new file mode 100644 index 00000000..bb54ff17 --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/src/devtool.ts @@ -0,0 +1,115 @@ +import { fileURLToPath } from 'node:url' +import { defineRpcFunction } from 'devframe' +import { defineDevtool } from 'devframe/types' +import * as v from 'valibot' + +const BASE_PATH = '/.devframe-streaming-chat/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +const CHANNEL_NAME = 'devframe-streaming-chat:tokens' + +const DEMO_PROMPTS = [ + 'Tell me about devframe.', + 'How does streaming work?', + 'Write a haiku about RPC.', +] as const + +/** + * Synthetic "AI" — splits a fake response into tokens and emits one + * every `intervalMs` milliseconds. Replaceable with any real provider + * (OpenAI SDK's `stream: true` returns an async iterable that pipes + * straight into `channel.pipeFrom(Readable.toWeb(stream))`). + */ +function* fakeTokens(prompt: string): Generator { + const lower = prompt.toLowerCase() + let response: string + if (lower.includes('haiku')) { + response = 'Tiny chunks arrive — / type-safe over WebSocket / streams compose with ease.' + } + else if (lower.includes('streaming')) { + response + = 'Streams start with `ctx.rpc.streaming.create()` on the server. ' + + 'Producers `write()` chunks; clients subscribe and consume them via ' + + '`for await (const chunk of reader)`. Cancellation, replay, and ' + + 'backpressure are wired by the host — your handler stays small.' + } + else { + response + = `You asked: "${prompt}". ` + + 'devframe is a framework-neutral foundation for building developer ' + + 'tooling — six adapters, type-safe RPC, shared state, and now a ' + + 'first-class streaming channel for delta-style server→client data. ' + + 'Pipe `ReadableStream`s directly into a sink, or write chunks by hand.' + } + // Split on whitespace but keep the spaces so `output.join('')` round-trips. + const tokens = response.split(/(\s+)/).filter(Boolean) + for (const token of tokens) yield token +} + +export default defineDevtool({ + id: 'devframe-streaming-chat', + name: 'Streaming Chat', + icon: 'ph:chat-circle-dots-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-streaming-chat', + port: 9897, + distDir, + }, + spa: { loader: 'none' }, + async setup(ctx) { + // Create the streaming channel up-front so demo prompts can reuse it. + const channel = ctx.rpc.streaming.create(CHANNEL_NAME, { + replayWindow: 256, + }) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:demo-prompts', + type: 'static', + jsonSerializable: true, + handler: () => ({ prompts: [...DEMO_PROMPTS] }), + })) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:start', + type: 'action', + jsonSerializable: true, + args: [v.object({ + prompt: v.string(), + intervalMs: v.optional(v.number(), 40), + })], + returns: v.object({ streamId: v.string() }), + handler: async ({ prompt, intervalMs = 40 }) => { + const stream = channel.start() + + // Producer task — fire-and-forget. Each chunk goes out the channel; + // we cooperate with cancellation by polling `stream.signal.aborted`. + ;(async () => { + try { + for (const token of fakeTokens(prompt)) { + if (stream.signal.aborted) + return + stream.write(token) + await new Promise(r => setTimeout(r, intervalMs)) + } + stream.close() + } + catch (err) { + stream.error(err) + } + })() + + return { streamId: stream.id } + }, + })) + + ctx.views.hostStatic(BASE_PATH, distDir) + ctx.docks.register({ + id: 'devframe-streaming-chat', + title: 'Streaming Chat', + icon: 'ph:chat-circle-dots-duotone', + type: 'iframe', + url: BASE_PATH, + }) + }, +}) diff --git a/devframe/examples/devframe-streaming-chat/tests/_utils.ts b/devframe/examples/devframe-streaming-chat/tests/_utils.ts new file mode 100644 index 00000000..dd1ab325 --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/tests/_utils.ts @@ -0,0 +1,76 @@ +import type { StartedServer } from 'devframe/node' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + DEVTOOLS_CONNECTION_META_FILENAME, +} from 'devframe/constants' +import { + createH3DevToolsHost, + createHostContext, + startHttpAndWs, +} from 'devframe/node' +import { getPort } from 'get-port-please' +import { createApp, eventHandler, fromNodeMiddleware } from 'h3' +import { resolve } from 'pathe' +import sirv from 'sirv' +import devtool from '../src/devtool' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +export const CLIENT_DIST = resolve(HERE, '../dist/client') + +/** + * Boot the streaming-chat server in-process for tests. Mirrors the + * cli adapter wiring so the WS+HTTP path is exercised end-to-end. + * + * Bound to 127.0.0.1 to avoid the IPv4/IPv6 race documented in + * `packages/devframe/src/rpc/transports/ws.test.ts`. + */ +export async function startStreamingChatServer(): Promise { + // Build the client only if a test exercises the served HTML — RPC-only + // tests don't need the dist (we don't call assertClientBuilt unless the + // test fetches index.html). + const distDir = devtool.cli!.distDir! + const basePath = devtool.basePath! + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + + const app = createApp() + const origin = `http://${host}:${port}` + const h3Host = createH3DevToolsHost({ + origin, + mount: (base, dir) => + app.use(base, fromNodeMiddleware(sirv(dir, { dev: true, single: true }))), + }) + + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) + await devtool.setup(ctx) + + const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use( + metaPath, + eventHandler((event) => { + event.node.res.setHeader('Content-Type', 'application/json') + return event.node.res.end( + JSON.stringify({ backend: 'websocket', websocket: port }), + ) + }), + ) + if (existsSync(path.join(resolve(distDir), 'index.html'))) { + app.use( + basePath, + fromNodeMiddleware(sirv(resolve(distDir), { dev: true, single: true })), + ) + } + + const server = await startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: false, + }) + + return Object.assign(server, { basePath }) +} diff --git a/devframe/examples/devframe-streaming-chat/tests/streaming-chat.test.ts b/devframe/examples/devframe-streaming-chat/tests/streaming-chat.test.ts new file mode 100644 index 00000000..3d4a8b5a --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/tests/streaming-chat.test.ts @@ -0,0 +1,168 @@ +import type { StartedServer } from 'devframe/node' +import { createRpcStreamingClientHost } from 'devframe/client' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { startStreamingChatServer } from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +const CHANNEL = 'devframe-streaming-chat:tokens' + +interface FakeClient { + rpc: ReturnType + streaming: ReturnType +} + +/** + * Build a minimal RPC client + streaming host. We don't go through + * `connectDevtool` because that needs a browser-like environment for + * connection-meta lookup; the WS channel is what matters for streaming. + */ +function bootClient(port: number): FakeClient { + const listeners = new Set<(trusted: boolean) => void>() + const fakeEvents = { + on(name: string, fn: (trusted: boolean) => void) { + if (name === 'rpc:is-trusted:updated') + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + + const clientFns: any = {} + const clientRpcStub = { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + } + + const rpc = createRpcClient( + clientFns, + { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), + }, + ) + + const fakeRpcClient = { + isTrusted: true, + events: fakeEvents, + client: clientRpcStub, + callEvent: (name: any, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + + const streaming = createRpcStreamingClientHost(fakeRpcClient) + return { rpc, streaming } +} + +describe('devframe-streaming-chat (example)', () => { + let server: StartedServer & { basePath: string } + + beforeEach(async () => { + server = await startStreamingChatServer() + }) + + afterEach(async () => { + await server?.close() + }) + + it('streams tokens for a prompt and joins back to the full response', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { streamId } = await (client.rpc as any).$call( + 'devframe-streaming-chat:start', + { prompt: 'Tell me about devframe.', intervalMs: 1 }, + ) as { streamId: string } + + const reader = client.streaming.subscribe(CHANNEL, streamId) + const collected: string[] = [] + for await (const token of reader) + collected.push(token) + + expect(collected.length).toBeGreaterThan(5) + expect(collected.join('')).toContain('devframe') + expect(collected.join('')).toContain('You asked') + }) + + it('cancellation aborts the producer mid-stream', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { streamId } = await (client.rpc as any).$call( + 'devframe-streaming-chat:start', + { prompt: 'How does streaming work?', intervalMs: 30 }, + ) as { streamId: string } + + const reader = client.streaming.subscribe(CHANNEL, streamId) + const collected: string[] = [] + + const consumer = (async () => { + for await (const token of reader) { + collected.push(token) + if (collected.length >= 3) + reader.cancel() + } + })() + + await consumer + const collectedAtCancel = collected.length + + // Wait long enough that more tokens would have arrived had we not + // cancelled, then assert no more came in. + await new Promise(r => setTimeout(r, 200)) + expect(collected.length).toBe(collectedAtCancel) + expect(reader.cancelled).toBe(true) + }) + + it('fans out the same chunks to two subscribers', async () => { + const a = bootClient(server.port) + const b = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { streamId } = await (a.rpc as any).$call( + 'devframe-streaming-chat:start', + { prompt: 'Write a haiku about RPC.', intervalMs: 1 }, + ) as { streamId: string } + + const readerA = a.streaming.subscribe(CHANNEL, streamId) + const readerB = b.streaming.subscribe(CHANNEL, streamId) + + const collectedA: string[] = [] + const collectedB: string[] = [] + await Promise.all([ + (async () => { for await (const t of readerA) collectedA.push(t) })(), + (async () => { for await (const t of readerB) collectedB.push(t) })(), + ]) + + expect(collectedA.join('')).toBe(collectedB.join('')) + // Haiku-prompt response uses these tokens; assert content matches the + // canned response so we know the producer routed by prompt correctly. + expect(collectedA.join('')).toContain('Tiny chunks arrive') + }) + + it('replays buffered tokens for a late subscriber within the replayWindow', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + // Fire the action; the producer starts emitting at intervalMs=1. + const { streamId } = await (client.rpc as any).$call( + 'devframe-streaming-chat:start', + { prompt: 'Tell me about devframe.', intervalMs: 5 }, + ) as { streamId: string } + + // Wait long enough that the producer has fully emitted into the + // server's ring buffer (size 256, more than enough for our response). + await new Promise(r => setTimeout(r, 800)) + + // Subscribe AFTER the producer has finished — the replay window + // means we still receive every token + the end frame. + const reader = client.streaming.subscribe(CHANNEL, streamId) + const collected: string[] = [] + for await (const token of reader) + collected.push(token) + + expect(collected.length).toBeGreaterThan(5) + expect(collected.join('')).toContain('You asked') + }) +}) diff --git a/devframe/examples/devframe-streaming-chat/tsconfig.json b/devframe/examples/devframe-streaming-chat/tsconfig.json new file mode 100644 index 00000000..16785ccf --- /dev/null +++ b/devframe/examples/devframe-streaming-chat/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "tests", "bin.mjs"] +} diff --git a/devframe/packages/devframe/package.json b/devframe/packages/devframe/package.json index 8446badd..09ddd21b 100644 --- a/devframe/packages/devframe/package.json +++ b/devframe/packages/devframe/package.json @@ -45,6 +45,7 @@ "./utils/promise": "./dist/utils/promise.mjs", "./utils/shared-state": "./dist/utils/shared-state.mjs", "./utils/state": "./dist/utils/state.mjs", + "./utils/streaming-channel": "./dist/utils/streaming-channel.mjs", "./utils/when": "./dist/utils/when.mjs", "./package.json": "./package.json" }, diff --git a/devframe/packages/devframe/src/client/index.ts b/devframe/packages/devframe/src/client/index.ts index 7e10f375..77862cf9 100644 --- a/devframe/packages/devframe/src/client/index.ts +++ b/devframe/packages/devframe/src/client/index.ts @@ -2,3 +2,4 @@ export * from './context' export * from './docks' export * from './rpc' export { getDevToolsRpcClient as connectDevtool } from './rpc' +export * from './rpc-streaming' diff --git a/devframe/packages/devframe/src/client/rpc-streaming.ts b/devframe/packages/devframe/src/client/rpc-streaming.ts new file mode 100644 index 00000000..9d175944 --- /dev/null +++ b/devframe/packages/devframe/src/client/rpc-streaming.ts @@ -0,0 +1,192 @@ +import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' +import type { DevToolsRpcClient } from './rpc' +import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' + +const STREAM_KEY_SEPARATOR = '\x1F' + +function streamKey(channel: string, id: string): string { + return `${channel}${STREAM_KEY_SEPARATOR}${id}` +} + +export interface StreamingSubscribeOptions { + /** Maximum buffered chunks before the oldest is dropped. Default 256. */ + highWaterMark?: number +} + +export interface RpcStreamingClientHost { + /** + * Subscribe to a server-side stream by channel + id. Returns a reader + * that's both an `AsyncIterable` (`for await`) and exposes + * `readable: ReadableStream` for `pipeTo`-style consumption. + */ + subscribe: ( + channel: string, + id: string, + options?: StreamingSubscribeOptions, + ) => StreamReader + /** + * Open the client side of a client-to-server upload. The id is + * typically obtained from a prior action call that ran + * `channel.openInbound()` on the server. Returns a `StreamSink` + * that mirrors the server-side producer surface (write / close / + * error / writable / signal). + * + * The sink's `signal` aborts when the server cancels the upload. + */ + upload: (channel: string, id: string) => StreamSink +} + +/** + * Client-side streaming host. Mirrors `createRpcSharedStateClientHost`: + * registers the two `:chunk` / `:end` event handlers once, then per-stream + * state lives in a `Map`. + */ +export function createRpcStreamingClientHost(rpc: DevToolsRpcClient): RpcStreamingClientHost { + const readers = new Map>() + const uploads = new Map>() + + rpc.client.register({ + name: 'devtoolskit:internal:streaming:chunk', + type: 'event', + handler(channel: string, id: string, seq: number, chunk: any) { + const reader = readers.get(streamKey(channel, id)) + reader?._push(seq, chunk) + }, + }) + + rpc.client.register({ + name: 'devtoolskit:internal:streaming:end', + type: 'event', + handler(channel: string, id: string, error?: StreamErrorPayload) { + const key = streamKey(channel, id) + const reader = readers.get(key) + if (!reader) + return + reader._end(error) + readers.delete(key) + }, + }) + + rpc.client.register({ + name: 'devtoolskit:internal:streaming:upload-cancel', + type: 'event', + handler(channel: string, id: string) { + const key = streamKey(channel, id) + const sink = uploads.get(key) + if (!sink) + return + // Server told us to stop — flip the sink's signal so producers + // observing it can short-circuit. We don't error() here so the + // local closer-of-record decides terminal state. + sink.abort('server cancelled upload') + uploads.delete(key) + }, + }) + + // Re-subscribe on reconnect — the server may have rebooted (lost state) + // OR the WS dropped briefly (state intact). Either way, sending `subscribe` + // with `afterSeq: lastSeenSeq` is the right thing: the server replays + // missed chunks if it has them, otherwise starts fresh. + rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + if (!isTrusted) + return + for (const [key, reader] of readers) { + if (reader.cancelled || reader.done) + continue + const sepIdx = key.indexOf(STREAM_KEY_SEPARATOR) + if (sepIdx < 0) + continue + const channel = key.slice(0, sepIdx) + const id = key.slice(sepIdx + 1) + rpc.callEvent( + 'devtoolskit:internal:streaming:subscribe', + channel, + id, + { afterSeq: reader.lastSeenSeq }, + ) + } + }) + + function subscribe( + channel: string, + id: string, + options: StreamingSubscribeOptions = {}, + ): StreamReader { + const key = streamKey(channel, id) + const existing = readers.get(key) + if (existing) + return existing as StreamReader + + const reader = createStreamReader({ + id, + highWaterMark: options.highWaterMark, + onOverflow(dropped) { + console.warn( + `[devframe] DF0029: Stream "${channel}#${id}" dropped ${dropped} chunk(s) ` + + `after exceeding the client high-water mark.`, + ) + }, + onCancel() { + rpc.callEvent('devtoolskit:internal:streaming:cancel', channel, id) + readers.delete(key) + }, + }) + + readers.set(key, reader) + + // Subscribe immediately if already trusted; otherwise wait for trust. + // Mirrors `client/rpc-shared-state.ts` behavior. + if (rpc.isTrusted) { + rpc.callEvent('devtoolskit:internal:streaming:subscribe', channel, id, { + afterSeq: 0, + }) + } + else { + const off = rpc.events.on('rpc:is-trusted:updated', (trusted) => { + if (trusted) { + off() + if (readers.has(key) && !reader.cancelled && !reader.done) { + rpc.callEvent('devtoolskit:internal:streaming:subscribe', channel, id, { + afterSeq: reader.lastSeenSeq, + }) + } + } + }) + } + + return reader + } + + function upload(channel: string, id: string): StreamSink { + const key = streamKey(channel, id) + const existing = uploads.get(key) + if (existing) + return existing as StreamSink + + const sink = createStreamSink({ id }) + + sink.events.on('chunk', (seq, chunk) => { + rpc.callEvent( + 'devtoolskit:internal:streaming:upload-chunk', + channel, + id, + seq, + chunk, + ) + }) + sink.events.on('end', (error) => { + rpc.callEvent( + 'devtoolskit:internal:streaming:upload-end', + channel, + id, + error, + ) + uploads.delete(key) + }) + + uploads.set(key, sink) + return sink + } + + return { subscribe, upload } +} diff --git a/devframe/packages/devframe/src/client/rpc.ts b/devframe/packages/devframe/src/client/rpc.ts index be5ed689..893a569f 100644 --- a/devframe/packages/devframe/src/client/rpc.ts +++ b/devframe/packages/devframe/src/client/rpc.ts @@ -3,6 +3,7 @@ import type { RpcCacheOptions } from 'devframe/rpc' import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' import type { DevToolsClientRpcHost, DevToolsRpcContext, RpcClientEvents } from './docks' +import type { RpcStreamingClientHost } from './rpc-streaming' import { DEVTOOLS_CONNECTION_META_FILENAME, } from 'devframe/constants' @@ -11,6 +12,7 @@ import { createEventEmitter } from 'devframe/utils/events' import { humanId } from 'devframe/utils/human-id' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' +import { createRpcStreamingClientHost } from './rpc-streaming' import { createWsRpcClientMode } from './rpc-ws' const CONNECTION_META_KEY = '__VITE_DEVTOOLS_CONNECTION_META__' @@ -87,6 +89,12 @@ export interface DevToolsRpcClient { * The shared state host */ sharedState: RpcSharedStateHost + /** + * The streaming channel host. Subscribe to a server-side stream by + * channel + id; the returned reader is both `AsyncIterable` and + * exposes `.readable: ReadableStream` for `pipeTo` consumption. + */ + streaming: RpcStreamingClientHost /** * The RPC cache manager */ @@ -282,10 +290,12 @@ export async function getDevToolsRpcClient( callOptional: mode.callOptional, client: clientRpc, sharedState: undefined!, + streaming: undefined!, cacheManager, } rpc.sharedState = createRpcSharedStateClientHost(rpc) + rpc.streaming = createRpcStreamingClientHost(rpc) // @ts-expect-error assign to readonly property context.rpc = rpc diff --git a/devframe/packages/devframe/src/node/__tests__/rpc-streaming.test.ts b/devframe/packages/devframe/src/node/__tests__/rpc-streaming.test.ts new file mode 100644 index 00000000..e5e95db8 --- /dev/null +++ b/devframe/packages/devframe/src/node/__tests__/rpc-streaming.test.ts @@ -0,0 +1,369 @@ +import type { DevToolsNodeContext, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import { AsyncLocalStorage } from 'node:async_hooks' +import { createRpcStreamingClientHost } from 'devframe/client' +import { createRpcClient } from 'devframe/rpc/client' +import { createRpcServer } from 'devframe/rpc/server' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { RpcFunctionsHost } from '../host-functions' + +vi.stubGlobal('WebSocket', WebSocket) + +let nextPort = 41000 +function allocatePort(): number { + return nextPort++ +} + +interface Harness { + port: number + rpcHost: RpcFunctionsHost + close: () => Promise +} + +async function bootHost(): Promise { + const port = allocatePort() + const mockContext = {} as DevToolsNodeContext + const rpcHost = new RpcFunctionsHost(mockContext) + + const asyncStorage = new AsyncLocalStorage() + + const rpcGroup = createRpcServer( + rpcHost.functions, + { + rpcOptions: { + resolver(_name, fn) { + // eslint-disable-next-line ts/no-this-alias + const rpc = this + if (!fn) + return undefined + return async function (this: any, ...args) { + return await asyncStorage.run({ rpc, meta: rpc.$meta }, async () => { + return (await fn).apply(this, args) + }) + } + }, + }, + }, + ) + + const { wss } = attachWsRpcTransport(rpcGroup, { + port, + host: '127.0.0.1', + onDisconnected: (_ws, meta) => { + rpcHost._emitSessionDisconnected(meta) + }, + }) + + ;(rpcHost as any)._rpcGroup = rpcGroup + ;(rpcHost as any)._asyncStorage = asyncStorage + + return { + port, + rpcHost, + async close() { + for (const ws of wss.clients) ws.terminate() + await new Promise(r => wss.close(() => r())) + }, + } +} + +interface FakeClient { + rpc: ReturnType> + streaming: ReturnType + close: () => void +} + +function bootClient(port: number): FakeClient { + // Mimic the minimal `DevToolsRpcClient` surface that + // `createRpcStreamingClientHost` uses (events, isTrusted, callEvent, + // client.register). + const listeners = new Set<(trusted: boolean) => void>() + const fakeEvents = { + on(name: string, fn: (trusted: boolean) => void) { + if (name === 'rpc:is-trusted:updated') + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + + // Lazily filled below, but the streaming host needs it during register(). + // We use the actual client functions object as the "client.register" target. + const clientFns: any = {} + const clientRpcStub = { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + } + + const rpc = createRpcClient( + clientFns, + { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), + }, + ) + + const fakeRpcClient = { + isTrusted: true, + events: fakeEvents, + client: clientRpcStub, + callEvent: (name: any, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + + const streaming = createRpcStreamingClientHost(fakeRpcClient) + + return { + rpc, + streaming, + close() { + // ws closes when server tears down + }, + } +} + +describe('rpc-streaming integration', () => { + let harness: Harness + + beforeEach(async () => { + harness = await bootHost() + }) + + afterEach(async () => { + await harness.close() + }) + + it('round-trips chunks from server to client in order', async () => { + const channel = harness.rpcHost.streaming.create('test:words', { + replayWindow: 64, + }) + + const client = bootClient(harness.port) + + // give the WS a moment to connect + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'words-1' }) + const reader = client.streaming.subscribe('test:words', 'words-1') + + // small delay so subscribe arrives before producer writes + await new Promise(r => setTimeout(r, 30)) + + stream.write('alpha') + stream.write('beta') + stream.write('gamma') + stream.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['alpha', 'beta', 'gamma']) + expect(reader.done).toBe(true) + }) + + it('replays buffered chunks for a late subscriber', async () => { + const channel = harness.rpcHost.streaming.create('test:replay', { + replayWindow: 64, + }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + // Producer runs first — subscriber comes later. + const stream = channel.start({ id: 'replay-1' }) + stream.write('one') + stream.write('two') + stream.write('three') + + const reader = client.streaming.subscribe('test:replay', 'replay-1') + await new Promise(r => setTimeout(r, 50)) + stream.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['one', 'two', 'three']) + }) + + it('aborts the server stream when a client cancels (single subscriber)', async () => { + const channel = harness.rpcHost.streaming.create('test:cancel') + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'cancel-1' }) + const reader = client.streaming.subscribe('test:cancel', 'cancel-1') + + await new Promise(r => setTimeout(r, 30)) + + expect(stream.signal.aborted).toBe(false) + reader.cancel() + + await vi.waitFor(() => { + expect(stream.signal.aborted).toBe(true) + }) + }) + + it('fans out the same chunks to two subscribers', async () => { + const channel = harness.rpcHost.streaming.create('test:fanout', { + replayWindow: 8, + }) + + const a = bootClient(harness.port) + const b = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'fan-1' }) + const readerA = a.streaming.subscribe('test:fanout', 'fan-1') + const readerB = b.streaming.subscribe('test:fanout', 'fan-1') + + await new Promise(r => setTimeout(r, 30)) + + stream.write('hello') + stream.write('world') + stream.close() + + const collectedA: string[] = [] + const collectedB: string[] = [] + for await (const chunk of readerA) collectedA.push(chunk) + for await (const chunk of readerB) collectedB.push(chunk) + + expect(collectedA).toEqual(['hello', 'world']) + expect(collectedB).toEqual(['hello', 'world']) + }) + + it('uploads chunks from client to server in order', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-happy') + const reader = channel.openInbound({ id: 'up-1' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-happy', 'up-1') + sink.write('alpha') + sink.write('beta') + sink.write('gamma') + sink.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('propagates client-side error to the server reader', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-error') + const reader = channel.openInbound({ id: 'up-2' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-error', 'up-2') + sink.write('hello') + sink.error(new Error('upstream-bork')) + + const collected: string[] = [] + let caught: unknown + try { + for await (const chunk of reader) + collected.push(chunk) + } + catch (e) { + caught = e + } + expect(collected).toEqual(['hello']) + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toBe('upstream-bork') + }) + + it('aborts the client sink when the server cancels', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-server-cancel') + const reader = channel.openInbound({ id: 'up-3' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-server-cancel', 'up-3') + sink.write('first') + + // Server consumes one chunk then cancels. + const consumed: string[] = [] + const consumer = (async () => { + for await (const chunk of reader) { + consumed.push(chunk) + if (consumed.length === 1) + reader.cancel() + } + })() + + await consumer + + await vi.waitFor(() => { + expect(sink.signal.aborted).toBe(true) + }) + expect(consumed).toEqual(['first']) + }) + + it('ends the server reader when the uploading client disconnects', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-disconnect') + const reader = channel.openInbound({ id: 'up-4' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-disconnect', 'up-4') + sink.write('mid-flight') + + // Drain at least the first chunk before terminating. + const collected: string[] = [] + const consumer = (async () => { + try { + for await (const chunk of reader) + collected.push(chunk) + } + catch { + // Disconnect surfaces as an error end frame; that's the test path. + } + })() + + await new Promise(r => setTimeout(r, 50)) + + // Slam the WS server side so the uploading session disconnects. + await harness.close() + + await consumer + expect(collected).toEqual(['mid-flight']) + }) + + it('keeps the stream alive when one of two subscribers cancels', async () => { + const channel = harness.rpcHost.streaming.create('test:fanout-cancel', { + replayWindow: 8, + }) + + const a = bootClient(harness.port) + const b = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'fan-2' }) + const readerA = a.streaming.subscribe('test:fanout-cancel', 'fan-2') + const readerB = b.streaming.subscribe('test:fanout-cancel', 'fan-2') + + await new Promise(r => setTimeout(r, 30)) + + stream.write('first') + await new Promise(r => setTimeout(r, 30)) + readerA.cancel() + await new Promise(r => setTimeout(r, 30)) + + expect(stream.signal.aborted).toBe(false) + + stream.write('second') + stream.close() + + const collectedB: string[] = [] + for await (const chunk of readerB) collectedB.push(chunk) + expect(collectedB).toEqual(['first', 'second']) + }) +}) diff --git a/devframe/packages/devframe/src/node/context.ts b/devframe/packages/devframe/src/node/context.ts index 48b8438b..a483a91f 100644 --- a/devframe/packages/devframe/src/node/context.ts +++ b/devframe/packages/devframe/src/node/context.ts @@ -137,13 +137,6 @@ export async function createHostContext(options: CreateHostContextOptions): Prom docksSharedState.mutate(() => context.docks.values()) }, mode === 'build' ? 0 : 10)) - terminalsHost.events.on('terminal:session:stream-chunk', (data) => { - rpcHost.broadcast({ - method: 'devtoolskit:internal:terminals:stream-chunk', - args: [data], - }) - }) - const debouncedMessagesUpdate = debounce(() => { rpcHost.broadcast({ method: 'devtoolskit:internal:messages:updated', diff --git a/devframe/packages/devframe/src/node/diagnostics.ts b/devframe/packages/devframe/src/node/diagnostics.ts index a61378d5..75e56907 100644 --- a/devframe/packages/devframe/src/node/diagnostics.ts +++ b/devframe/packages/devframe/src/node/diagnostics.ts @@ -72,6 +72,27 @@ export const diagnostics = defineDiagnostics({ hint: 'Replace any access to `ctx.logs` (or `context.logs`) with `ctx.messages`. The runtime behavior is identical.', level: 'warn', }, + DF0029: { + message: (p: { channel: string, id: string, dropped: number }) => + `Stream "${p.channel}#${p.id}" dropped ${p.dropped} chunk(s) after exceeding the client high-water mark.`, + hint: 'The consumer is too slow for the producer. Raise `highWaterMark` on the subscription, slow the producer, or batch chunks.', + level: 'warn', + }, + DF0030: { + message: (p: { channel: string, id: string }) => + `Stream "${p.channel}#${p.id}" is unknown — no producer has called \`channel.start({ id: "${p.id}" })\`.`, + hint: 'Ensure the server-side producer is running before clients subscribe, or check for typos in the stream id.', + }, + DF0031: { + message: (p: { channel: string, id: string }) => + `Cannot write to closed stream "${p.channel}#${p.id}".`, + hint: 'Track the producer lifecycle — guard writes with the `stream.signal.aborted` flag.', + }, + DF0032: { + message: (p: { channel: string }) => + `Streaming channel "${p.channel}" is already registered.`, + hint: 'Each channel name must be unique within a context. Pick a different name or reuse the existing channel handle.', + }, }, }) diff --git a/devframe/packages/devframe/src/node/host-functions.ts b/devframe/packages/devframe/src/node/host-functions.ts index fd1dd8e8..66c4a301 100644 --- a/devframe/packages/devframe/src/node/host-functions.ts +++ b/devframe/packages/devframe/src/node/host-functions.ts @@ -1,10 +1,11 @@ import type { BirpcGroup } from 'birpc' -import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost } from 'devframe/types' +import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsNodeRpcSessionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost, RpcStreamingHost } from 'devframe/types' import type { AsyncLocalStorage } from 'node:async_hooks' import { RpcFunctionsCollectorBase } from 'devframe/rpc' import { createDebug } from 'obug' import { logger } from './diagnostics' import { createRpcSharedStateServerHost } from './rpc-shared-state' +import { createRpcStreamingServerHost } from './rpc-streaming' const debugBroadcast = createDebug('vite:devtools:rpc:broadcast') @@ -19,9 +20,22 @@ export class RpcFunctionsHost extends RpcFunctionsCollectorBase() + private _channel?: RpcStreamingChannel + constructor( public readonly context: DevToolsNodeContext, ) { } + /** + * Lazily acquire the streaming channel — `context.rpc` isn't assigned + * until after every host is constructed, so we can't grab it in the + * constructor. + */ + private getStreamingChannel(): RpcStreamingChannel | undefined { + if (this._channel) + return this._channel + if (!this.context.rpc?.streaming) + return undefined + this._channel = this.context.rpc.streaming.create( + TERMINAL_STREAM_CHANNEL, + { replayWindow: TERMINAL_REPLAY_WINDOW }, + ) + return this._channel + } + register(session: DevToolsTerminalSession): DevToolsTerminalSession { if (this.sessions.has(session.id)) { throw logger.DF0004({ id: session.id }).throw() @@ -60,21 +87,35 @@ export class DevToolsTerminalHost implements DevToolsTerminalHostType { return session.buffer ||= [] - const events = this.events + const sessionBuffer = session.buffer + + const channel = this.getStreamingChannel() + // The streaming channel reuses `session.id` as the stream id so clients + // can subscribe immediately after seeing the session in + // `devtoolskit:internal:terminals:list`. + const sink = channel?.start({ id: session.id }) + const writer = new WritableStream({ write(chunk) { - session.buffer!.push(chunk) - events.emit('terminal:session:stream-chunk', { - id: session.id, - chunks: [chunk], - ts: Date.now(), - }) + // Mirror to the legacy session.buffer used by `terminals:read` — + // unbounded history kept for the snapshot endpoint. + sessionBuffer.push(chunk) + sink?.write(chunk) + }, + close() { + sink?.close() }, + abort(reason) { + sink?.error(reason) + }, + }) + session.stream.pipeTo(writer).catch(() => { + // pipeTo rejection surfaces via writer.abort -> sink.error already. }) - session.stream.pipeTo(writer) this._boundStreams.set(session.id, { dispose: () => { - writer.close() + if (sink && !sink.closed) + sink.close() }, stream: session.stream, }) diff --git a/devframe/packages/devframe/src/node/index.ts b/devframe/packages/devframe/src/node/index.ts index d2a374f0..13766b48 100644 --- a/devframe/packages/devframe/src/node/index.ts +++ b/devframe/packages/devframe/src/node/index.ts @@ -14,6 +14,7 @@ export * from './host-messages' export * from './host-terminals' export * from './host-views' export * from './rpc-shared-state' +export * from './rpc-streaming' export * from './server' export * from './static-dump' export * from './storage' diff --git a/devframe/packages/devframe/src/node/rpc-streaming.ts b/devframe/packages/devframe/src/node/rpc-streaming.ts new file mode 100644 index 00000000..0f13cf5a --- /dev/null +++ b/devframe/packages/devframe/src/node/rpc-streaming.ts @@ -0,0 +1,378 @@ +import type { + DevToolsNodeRpcSessionMeta, + RpcFunctionsHost, + RpcStreamingChannel, + RpcStreamingChannelOptions, + RpcStreamingHost, +} from 'devframe/types' +import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' +import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' +import { createDebug } from 'obug' +import { logger } from './diagnostics' + +const debug = createDebug('vite:devtools:rpc:streaming') + +const STREAM_KEY_SEPARATOR = '\x1F' + +function streamKey(channel: string, id: string): string { + return `${channel}${STREAM_KEY_SEPARATOR}${id}` +} + +interface ServerStreamRecord { + sink: StreamSink + subscribers: Set + unbinders: (() => void)[] + /** Timer scheduled when stream closes with no subscribers; cleared on resubscribe. */ + retentionTimer?: ReturnType +} + +interface ServerInboundRecord { + reader: StreamReader + /** First session that wrote to this inbound — locks ownership for cleanup. */ + uploaderMeta?: DevToolsNodeRpcSessionMeta +} + +interface ChannelState { + name: string + options: Required + streams: Map> + inbound: Map> +} + +/** + * Build the server-side streaming host. Mirrors the layout of + * `createRpcSharedStateServerHost` — registers a fixed set of internal + * RPC methods (`subscribe` / `unsubscribe` / `cancel`) once, then per-channel + * state lives in a `Map`. + */ +export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamingHost { + const channels = new Map() + + function findStream(channelName: string, id: string): ServerStreamRecord | undefined { + return channels.get(channelName)?.streams.get(id) + } + + function freeStreamNow(state: ChannelState, id: string): void { + const record = state.streams.get(id) + if (!record) + return + if (record.retentionTimer) { + clearTimeout(record.retentionTimer) + record.retentionTimer = undefined + } + for (const off of record.unbinders) off() + state.streams.delete(id) + debug('freed', state.name, id) + } + + function maybeFreeStream(state: ChannelState, id: string): void { + const record = state.streams.get(id) + if (!record) + return + if (!record.sink.closed || record.subscribers.size > 0) + return + + // Closed and no subscribers — either free now or hold for replay. + const retention = state.options.closedStreamRetention + if (retention <= 0) { + freeStreamNow(state, id) + return + } + // Schedule free unless a subscriber resurrects the stream first. + if (record.retentionTimer) + return // already scheduled + record.retentionTimer = setTimeout(freeStreamNow, retention, state, id) + } + + function cancelRetention(record: ServerStreamRecord): void { + if (record.retentionTimer) { + clearTimeout(record.retentionTimer) + record.retentionTimer = undefined + } + } + + rpc.register({ + name: 'devtoolskit:internal:streaming:subscribe', + type: 'event', + handler(channelName: string, id: string, opts?: { afterSeq?: number }) { + const state = channels.get(channelName) + if (!state) { + logger.DF0030({ channel: channelName, id }).log() + return + } + const record = state.streams.get(id) + if (!record) { + logger.DF0030({ channel: channelName, id }).log() + return + } + const session = rpc.getCurrentRpcSession() + if (!session) + return + const key = streamKey(channelName, id) + session.meta.subscribedStreams ??= new Set() + session.meta.subscribedStreams.add(key) + record.subscribers.add(session.meta) + cancelRetention(record) + + const afterSeq = opts?.afterSeq ?? 0 + for (const buffered of record.sink.buffer) { + if (buffered.seq > afterSeq) { + rpc.broadcast({ + method: 'devtoolskit:internal:streaming:chunk', + args: [channelName, id, buffered.seq, buffered.chunk], + event: true, + optional: true, + filter: client => client.$meta === session.meta, + }) + } + } + if (record.sink.closed) { + rpc.broadcast({ + method: 'devtoolskit:internal:streaming:end', + args: [channelName, id, undefined], + event: true, + optional: true, + filter: client => client.$meta === session.meta, + }) + } + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:streaming:unsubscribe', + type: 'event', + handler(channelName: string, id: string) { + const state = channels.get(channelName) + const record = state?.streams.get(id) + const session = rpc.getCurrentRpcSession() + if (!session) + return + session.meta.subscribedStreams?.delete(streamKey(channelName, id)) + if (state && record) { + record.subscribers.delete(session.meta) + maybeFreeStream(state, id) + } + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:streaming:cancel', + type: 'event', + handler(channelName: string, id: string) { + const record = findStream(channelName, id) + if (!record) + return + // Cooperative cancel — only abort if the cancelling session was the + // last subscriber. Otherwise other clients still want the stream. + const session = rpc.getCurrentRpcSession() + if (!session) + return + record.subscribers.delete(session.meta) + session.meta.subscribedStreams?.delete(streamKey(channelName, id)) + if (record.subscribers.size === 0) + record.sink.abort('cancelled by client') + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:streaming:upload-chunk', + type: 'event', + handler(channelName: string, id: string, seq: number, chunk: any) { + const state = channels.get(channelName) + const record = state?.inbound.get(id) + if (!record) { + logger.DF0030({ channel: channelName, id }).log() + return + } + // Lock the inbound to the first session that writes; subsequent + // chunks from a different session are ignored. The action handler + // returned the id to one specific caller, so this is the + // expected ownership model. + if (!record.uploaderMeta) { + const session = rpc.getCurrentRpcSession() + if (session) { + record.uploaderMeta = session.meta + session.meta.uploadingStreams ??= new Set() + session.meta.uploadingStreams.add(streamKey(channelName, id)) + } + } + record.reader._push(seq, chunk) + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:streaming:upload-end', + type: 'event', + handler(channelName: string, id: string, error?: StreamErrorPayload) { + const state = channels.get(channelName) + const record = state?.inbound.get(id) + if (!record) + return + record.reader._end(error) + if (record.uploaderMeta) { + record.uploaderMeta.uploadingStreams?.delete(streamKey(channelName, id)) + } + state?.inbound.delete(id) + }, + }) + + function createChannel(name: string, opts: RpcStreamingChannelOptions = {}): RpcStreamingChannel { + if (channels.has(name)) + throw logger.DF0032({ channel: name }).throw() + + const replayWindow = opts.replayWindow ?? 0 + const state: ChannelState = { + name, + options: { + replayWindow, + // Default to a 30-second hold when replay is enabled so late + // subscribers can join after the producer finishes. + closedStreamRetention: opts.closedStreamRetention ?? (replayWindow > 0 ? 30_000 : 0), + }, + streams: new Map(), + inbound: new Map(), + } + channels.set(name, state) + + function start(startOpts: { id?: string } = {}): StreamSink { + const sink = createStreamSink({ + id: startOpts.id, + replayWindow: state.options.replayWindow, + }) + + const record: ServerStreamRecord = { + sink, + subscribers: new Set(), + unbinders: [], + } + state.streams.set(sink.id, record) + + record.unbinders.push( + sink.events.on('chunk', (seq, chunk) => { + rpc.broadcast({ + method: 'devtoolskit:internal:streaming:chunk', + args: [name, sink.id, seq, chunk], + event: true, + optional: true, + filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + }) + }), + ) + record.unbinders.push( + sink.events.on('end', (error) => { + rpc.broadcast({ + method: 'devtoolskit:internal:streaming:end', + args: [name, sink.id, error], + event: true, + optional: true, + filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + }) + maybeFreeStream(state, sink.id) + }), + ) + + return sink + } + + async function pipeFrom(readable: ReadableStream, startOpts: { id?: string } = {}): Promise> { + const sink = start(startOpts) + readable.pipeTo(sink.writable, { signal: sink.signal }).catch(() => { + // Errors flow through the writable's `abort` → `sink.error`. + // The pipeTo rejection is informational only. + }) + return sink + } + + function get(id: string): StreamSink | undefined { + return state.streams.get(id)?.sink + } + + function ids(): string[] { + return Array.from(state.streams.keys()) + } + + function openInbound(inboundOpts: { id?: string } = {}): StreamReader { + // Forward-declared so `onCancel` can read the uploader meta that's + // assigned later (when the first chunk arrives). + let inboundRecord: ServerInboundRecord + const reader = createStreamReader({ + id: inboundOpts.id, + onCancel() { + // Server-initiated cancel — tell the uploading client to stop. + // The cancel is targeted at the session that owns this inbound. + const targetMeta = inboundRecord?.uploaderMeta + if (!targetMeta) + return + rpc.broadcast({ + method: 'devtoolskit:internal:streaming:upload-cancel', + args: [name, reader.id], + event: true, + optional: true, + filter: client => client.$meta === targetMeta, + }) + }, + }) + + inboundRecord = { reader } + state.inbound.set(reader.id, inboundRecord) + debug('opened-inbound', name, reader.id) + + return reader + } + + return { name, start, pipeFrom, get, ids, openInbound } + } + + function parseKey(key: string): { channelName: string, id: string } | undefined { + const sepIdx = key.indexOf(STREAM_KEY_SEPARATOR) + if (sepIdx < 0) + return undefined + return { channelName: key.slice(0, sepIdx), id: key.slice(sepIdx + 1) } + } + + return { + create: createChannel, + _onSessionDisconnected(meta: DevToolsNodeRpcSessionMeta) { + // Outbound: drop subscriber, abort if last one drops. + if (meta.subscribedStreams) { + for (const key of meta.subscribedStreams) { + const parsed = parseKey(key) + if (!parsed) + continue + const state = channels.get(parsed.channelName) + const record = state?.streams.get(parsed.id) + if (!state || !record) + continue + record.subscribers.delete(meta) + if (record.subscribers.size === 0 && !record.sink.closed) { + // Last subscriber gone — abort so the producer can short-circuit. + record.sink.abort('all subscribers disconnected') + } + maybeFreeStream(state, parsed.id) + } + meta.subscribedStreams.clear() + } + + // Inbound: end the reader with an error so the consuming handler + // exits cleanly. Each inbound is owned by one session so we just + // free it. + if (meta.uploadingStreams) { + for (const key of meta.uploadingStreams) { + const parsed = parseKey(key) + if (!parsed) + continue + const state = channels.get(parsed.channelName) + const record = state?.inbound.get(parsed.id) + if (!state || !record) + continue + record.reader._end({ + name: 'UploadDisconnected', + message: 'Uploader disconnected before completing the stream', + }) + state.inbound.delete(parsed.id) + } + meta.uploadingStreams.clear() + } + }, + } +} diff --git a/devframe/packages/devframe/src/node/server.ts b/devframe/packages/devframe/src/node/server.ts index bafe54d6..77c11e5c 100644 --- a/devframe/packages/devframe/src/node/server.ts +++ b/devframe/packages/devframe/src/node/server.ts @@ -1,5 +1,5 @@ import type { BirpcGroup } from 'birpc' -import type { DevToolsNodeContext, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' import type { App } from 'h3' import type { WebSocketServer } from 'ws' import type { RpcFunctionsHost } from './host-functions' @@ -61,13 +61,42 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise() + const asyncStorage = new AsyncLocalStorage() const rpcGroup = createRpcServer( rpcHost.functions, + { + rpcOptions: { + // Wrap each RPC handler in an AsyncLocalStorage context so + // `ctx.rpc.getCurrentRpcSession()` works inside handlers (used + // by streaming subscribe/unsubscribe/cancel and shared-state + // sync). Mirrors `packages/core/src/node/ws.ts`'s resolver, + // minus the auth gate (devframe defers auth to its host + // adapters; the standalone CLI server is unauthenticated). + resolver(name, fn) { + // eslint-disable-next-line ts/no-this-alias + const rpc = this + if (!fn) + return undefined + return async function (this: any, ...args) { + return await asyncStorage.run({ + rpc, + meta: rpc.$meta, + }, async () => { + return (await fn).apply(this, args) + }) + } + }, + }, + }, ) - attachWsRpcTransport(rpcGroup, { wss }) + attachWsRpcTransport(rpcGroup, { + wss, + onDisconnected: (_ws, meta) => { + rpcHost._emitSessionDisconnected(meta) + }, + }) ;(rpcHost as any)._rpcGroup = rpcGroup ;(rpcHost as any)._asyncStorage = asyncStorage diff --git a/devframe/packages/devframe/src/rpc/transports/ws-server.ts b/devframe/packages/devframe/src/rpc/transports/ws-server.ts index 1b9a98e4..62aaecfd 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-server.ts +++ b/devframe/packages/devframe/src/rpc/transports/ws-server.ts @@ -18,6 +18,18 @@ export interface DevToolsNodeRpcSessionMeta { clientAuthToken?: string isTrusted?: boolean subscribedStates: Set + /** + * Streams this session has subscribed to via + * `rpc.streaming.subscribe(channel, id)`. Tracked here for O(1) cleanup + * on disconnect; the wire format is `${channel}\x1F${id}`. + */ + subscribedStreams?: Set + /** + * Inbound streams this session is currently uploading to (via + * `rpc.streaming.upload(channel, id)`). Tracked for cleanup on + * disconnect; same wire format as `subscribedStreams`. + */ + uploadingStreams?: Set } export interface WsRpcTransportOptions { diff --git a/devframe/packages/devframe/src/types/rpc-augments.ts b/devframe/packages/devframe/src/types/rpc-augments.ts index 5714b7d7..973cbb1c 100644 --- a/devframe/packages/devframe/src/types/rpc-augments.ts +++ b/devframe/packages/devframe/src/types/rpc-augments.ts @@ -1,12 +1,70 @@ /** * To be extended */ -export interface DevToolsRpcClientFunctions {} +export interface DevToolsRpcClientFunctions { + /** + * Streaming chunk pushed from server to subscribed clients. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:chunk': (channel: string, id: string, seq: number, chunk: any) => Promise + /** + * Streaming terminator pushed from server to subscribed clients. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:end': (channel: string, id: string, error?: { name: string, message: string }) => Promise + /** + * Server→client cancel for an in-flight upload. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:upload-cancel': (channel: string, id: string) => Promise +} /** * To be extended */ -export interface DevToolsRpcServerFunctions {} +export interface DevToolsRpcServerFunctions { + /** + * Client→server streaming subscription with optional replay cursor. + * Wired by `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:subscribe': (channel: string, id: string, opts?: { afterSeq?: number }) => Promise + /** + * Client→server streaming unsubscribe. Wired by `RpcStreamingHost`; + * do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:unsubscribe': (channel: string, id: string) => Promise + /** + * Client→server streaming cancellation request. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:cancel': (channel: string, id: string) => Promise + /** + * Client→server upload chunk. Wired by `RpcStreamingHost`; do not + * register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:upload-chunk': (channel: string, id: string, seq: number, chunk: any) => Promise + /** + * Client→server upload terminator. Wired by `RpcStreamingHost`; do not + * register manually. + * + * @internal + */ + 'devtoolskit:internal:streaming:upload-end': (channel: string, id: string, error?: { name: string, message: string }) => Promise +} /** * To be extended diff --git a/devframe/packages/devframe/src/types/rpc.ts b/devframe/packages/devframe/src/types/rpc.ts index 5b2518ba..5787f7f0 100644 --- a/devframe/packages/devframe/src/types/rpc.ts +++ b/devframe/packages/devframe/src/types/rpc.ts @@ -2,6 +2,7 @@ import type { BirpcReturn } from 'birpc' import type { RpcFunctionsCollectorBase } from 'devframe/rpc' import type { DevToolsNodeRpcSessionMeta } from 'devframe/rpc/transports/ws-server' import type { SharedState } from '../utils/shared-state' +import type { StreamReader, StreamSink } from '../utils/streaming-channel' import type { DevToolsNodeContext } from './context' import type { DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, DevToolsRpcSharedStates } from './rpc-augments' @@ -55,6 +56,14 @@ export type RpcFunctionsHost = RpcFunctionsCollectorBase { @@ -73,3 +82,103 @@ export interface RpcSharedStateHost { */ onKeyAdded: (fn: (key: string) => void) => () => void } + +/** + * Options for `RpcStreamingHost.create()`. + */ +export interface RpcStreamingChannelOptions { + /** + * Size of the per-stream ring buffer kept on the server for + * replay-on-resubscribe. `0` (default) disables replay; on reconnect + * the consumer only sees chunks that arrive after subscribing. + * + * The buffer is per stream id, not per channel — each `channel.start()` + * gets its own. + */ + replayWindow?: number + /** + * Milliseconds a closed stream is retained on the server after its + * last subscriber leaves (or if no subscriber ever arrived). During + * this window, late subscribers can still join and replay the buffer + * + receive the `end` frame. + * + * Defaults to `30_000` (30 s) when `replayWindow > 0`, else `0` + * (immediate free). Set to `0` to opt out, or higher for longer + * post-mortem replay. + */ + closedStreamRetention?: number +} + +/** + * Channel handle returned by `ctx.rpc.streaming.create(name, opts)`. A + * channel owns a wire namespace; calling `start()` produces individual + * streams keyed by id. + * + * @see {@link https://devtools.vite.dev/devframe/guide/streaming Streaming guide} + */ +export interface RpcStreamingChannel { + /** Channel name as registered with `ctx.rpc.streaming.create()`. */ + readonly name: string + /** + * Start a new stream. Returns a server-side sink with both an imperative + * (`write` / `close` / `error`) surface and a `WritableStream` for + * `pipeTo` consumption. The sink's `signal` aborts when every subscriber + * disconnects or cancels. + */ + start: (opts?: { id?: string }) => StreamSink + /** + * Convenience: start a stream and pipe a `ReadableStream` into it. + * The pipe uses `sink.signal` so cancellation propagates upstream. + * + * Node-stream interop: convert a `Readable` with `Readable.toWeb(node)` + * before passing it here. + */ + pipeFrom: (readable: ReadableStream, opts?: { id?: string }) => Promise> + /** Look up an active stream by id. Returns `undefined` if none. */ + get: (id: string) => StreamSink | undefined + /** All active outbound stream ids on this channel. */ + ids: () => string[] + /** + * Open an inbound stream — the server side of a client-to-server + * upload. Allocates an id, returns a `StreamReader` that fills as + * the client writes chunks. Typical pattern is to call this from an + * action handler, kick off background processing, and return the id + * so the caller can start uploading: + * + * ```ts + * handler: async () => { + * const reader = channel.openInbound() + * ;(async () => { + * for await (const chunk of reader) processChunk(chunk) + * })() + * return { uploadId: reader.id } + * } + * ``` + * + * Calling `reader.cancel()` on the server sends an `upload-cancel` to + * the uploading client, which aborts its sink. + */ + openInbound: (opts?: { id?: string }) => StreamReader +} + +/** + * Server-side streaming host. Lives on `ctx.rpc.streaming` alongside + * `ctx.rpc.sharedState`. Each named channel owns its own stream registry + * and wire namespace. + */ +export interface RpcStreamingHost { + /** + * Register a streaming channel. Names follow the `:` + * convention (e.g. `'my-devtool:chat-stream'`). Throws `DF0032` if the + * name is already taken. + */ + create: (name: string, opts?: RpcStreamingChannelOptions) => RpcStreamingChannel + /** + * Adapters call this when a session disconnects so the host can drop + * subscribers and abort orphaned streams. Most users do not need this; + * it's wired by `startHttpAndWs` automatically. + * + * @internal + */ + _onSessionDisconnected: (meta: DevToolsNodeRpcSessionMeta) => void +} diff --git a/devframe/packages/devframe/src/types/terminals.ts b/devframe/packages/devframe/src/types/terminals.ts index 2bd2c3b0..77edb457 100644 --- a/devframe/packages/devframe/src/types/terminals.ts +++ b/devframe/packages/devframe/src/types/terminals.ts @@ -2,17 +2,10 @@ import type { ChildProcess } from 'node:child_process' import type { DevToolsDockEntryIcon } from './docks' import type { EventEmitter } from './events' -export interface DevToolsTerminalSessionStreamChunkEvent { - id: string - chunks: string[] - ts: number -} - export interface DevToolsTerminalHost { readonly sessions: Map readonly events: EventEmitter<{ 'terminal:session:updated': (session: DevToolsTerminalSession) => void - 'terminal:session:stream-chunk': (data: DevToolsTerminalSessionStreamChunkEvent) => void }> register: (session: DevToolsTerminalSession) => DevToolsTerminalSession diff --git a/devframe/packages/devframe/src/utils/streaming-channel.test.ts b/devframe/packages/devframe/src/utils/streaming-channel.test.ts new file mode 100644 index 00000000..2a736d95 --- /dev/null +++ b/devframe/packages/devframe/src/utils/streaming-channel.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest' +import { createStreamReader, createStreamSink } from './streaming-channel' + +describe('streaming-channel sink', () => { + it('emits chunk events with monotonic seq', () => { + const sink = createStreamSink() + const seen: Array<[number, string]> = [] + sink.events.on('chunk', (seq, chunk) => seen.push([seq, chunk])) + + sink.write('a') + sink.write('b') + sink.write('c') + + expect(seen).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]) + expect(sink.lastSeq).toBe(3) + }) + + it('emits end exactly once on close() — second close is a no-op', () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + sink.close() + sink.close() + + expect(end).toHaveBeenCalledTimes(1) + expect(end).toHaveBeenCalledWith(undefined) + }) + + it('throws on write after close', () => { + const sink = createStreamSink() + sink.close() + expect(() => sink.write('x')).toThrow(/closed stream/) + }) + + it('emits end with error payload on error()', () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + sink.error(new TypeError('boom')) + + expect(end).toHaveBeenCalledWith({ name: 'TypeError', message: 'boom' }) + expect(sink.signal.aborted).toBe(true) + }) + + it('keeps a ring buffer up to replayWindow', () => { + const sink = createStreamSink({ replayWindow: 2 }) + sink.write('a') + sink.write('b') + sink.write('c') + + expect(sink.buffer.map(b => b.chunk)).toEqual(['b', 'c']) + expect(sink.buffer.map(b => b.seq)).toEqual([2, 3]) + }) + + it('keeps no buffer by default (replayWindow = 0)', () => { + const sink = createStreamSink() + sink.write('a') + sink.write('b') + expect(sink.buffer.length).toBe(0) + }) + + it('aborts signal on close so handlers can short-circuit', () => { + const sink = createStreamSink() + expect(sink.signal.aborted).toBe(false) + sink.close() + expect(sink.signal.aborted).toBe(true) + }) + + it('exposes a WritableStream that mirrors imperative writes', async () => { + const sink = createStreamSink() + const seen: Array<[number, string]> = [] + sink.events.on('chunk', (seq, chunk) => seen.push([seq, chunk])) + + const source = new ReadableStream({ + start(controller) { + controller.enqueue('x') + controller.enqueue('y') + controller.close() + }, + }) + await source.pipeTo(sink.writable) + + expect(seen).toEqual([[1, 'x'], [2, 'y']]) + expect(sink.closed).toBe(true) + }) + + it('errors the sink when writable abort fires', async () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('upstream-failed')) + }, + }) + await source.pipeTo(sink.writable).catch(() => {}) + + expect(end).toHaveBeenCalledWith({ name: 'Error', message: 'upstream-failed' }) + }) +}) + +describe('streaming-channel reader', () => { + it('iterates chunks pushed by the RPC layer', async () => { + const reader = createStreamReader() + reader._push(1, 10) + reader._push(2, 20) + reader._end() + + const collected: number[] = [] + for await (const v of reader) collected.push(v) + + expect(collected).toEqual([10, 20]) + expect(reader.done).toBe(true) + expect(reader.lastSeenSeq).toBe(2) + }) + + it('blocks `for await` until next push', async () => { + const reader = createStreamReader() + const collected: number[] = [] + const consumer = (async () => { + for await (const v of reader) collected.push(v) + })() + + await Promise.resolve() + reader._push(1, 1) + await Promise.resolve() + reader._push(2, 2) + reader._end() + await consumer + + expect(collected).toEqual([1, 2]) + }) + + it('rejects the iterator with a real Error on _end with payload', async () => { + const reader = createStreamReader() + reader._end({ name: 'TypeError', message: 'boom' }) + + await expect((async () => { + for await (const _ of reader) { /* noop */ } + })()).rejects.toThrow(/boom/) + }) + + it('dedupes chunks with seq <= lastSeenSeq (replay)', async () => { + const reader = createStreamReader() + reader._push(1, 100) + reader._push(2, 200) + // Replayed + reader._push(1, 100) + reader._push(2, 200) + reader._push(3, 300) + reader._end() + + const collected: number[] = [] + for await (const v of reader) collected.push(v) + expect(collected).toEqual([100, 200, 300]) + }) + + it('drops oldest chunks on overflow and reports count', () => { + const onOverflow = vi.fn() + const reader = createStreamReader({ highWaterMark: 2, onOverflow }) + + reader._push(1, 1) + reader._push(2, 2) + reader._push(3, 3) + reader._push(4, 4) + + expect(onOverflow).toHaveBeenCalled() + // Queue should be capped at highWaterMark + expect(reader.lastSeenSeq).toBe(4) + }) + + it('cancel() invokes onCancel and ends the stream cleanly', async () => { + const onCancel = vi.fn() + const reader = createStreamReader({ onCancel }) + + const collected: number[] = [] + const consumer = (async () => { + for await (const v of reader) collected.push(v) + })() + + reader._push(1, 1) + await Promise.resolve() + reader.cancel() + await consumer + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(reader.cancelled).toBe(true) + expect(collected).toEqual([1]) + }) + + it('exposes a ReadableStream that surfaces the same chunks', async () => { + const reader = createStreamReader() + + const collected: string[] = [] + const piped = (async () => { + for await (const v of streamToAsyncIter(reader.readable)) + collected.push(v) + })() + + reader._push(1, 'x') + reader._push(2, 'y') + reader._end() + + await piped + expect(collected).toEqual(['x', 'y']) + }) + + it('cancelling the ReadableStream cancels the reader', async () => { + const onCancel = vi.fn() + const reader = createStreamReader({ onCancel }) + + const r = reader.readable.getReader() + await r.cancel('user-stop') + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(reader.cancelled).toBe(true) + }) +}) + +async function* streamToAsyncIter(stream: ReadableStream): AsyncIterable { + const reader = stream.getReader() + try { + while (true) { + const { value, done } = await reader.read() + if (done) + return + yield value + } + } + finally { + reader.releaseLock() + } +} diff --git a/devframe/packages/devframe/src/utils/streaming-channel.ts b/devframe/packages/devframe/src/utils/streaming-channel.ts new file mode 100644 index 00000000..9a8a9f2a --- /dev/null +++ b/devframe/packages/devframe/src/utils/streaming-channel.ts @@ -0,0 +1,390 @@ +import type { EventEmitter } from 'devframe/types' +import { createEventEmitter } from './events' +import { nanoid } from './nanoid' + +/** + * Serialized error shape sent over the wire when a stream ends with a failure. + * Stays JSON-safe so the strict-JSON encoder can carry it without coercion. + */ +export interface StreamErrorPayload { + name: string + message: string +} + +/** + * Single buffered chunk in the server-side ring buffer. + * + * Sequence numbers start at 1 and increment per write. Subscribers track + * `lastSeenSeq` and ask for `afterSeq` on resubscribe so the server can + * replay any chunks the client missed during a brief disconnect. + */ +export interface BufferedChunk { + seq: number + chunk: T +} + +export interface StreamSinkEvents { + /** Fired for each `write()`. The RPC layer subscribes and broadcasts. */ + chunk: (seq: number, chunk: T) => void + /** Terminal — fired exactly once per sink lifetime. */ + end: (error?: StreamErrorPayload) => void +} + +export interface CreateStreamSinkOptions { + id?: string + /** + * Size of the per-stream ring buffer kept for replay-on-resubscribe. + * `0` (default) disables replay. + */ + replayWindow?: number +} + +/** + * Server-side producer handle. Two equivalent surfaces share one piece of + * state: the imperative `write/error/close` triple, and a `WritableStream` + * for `pipeTo`-style consumption. + */ +export interface StreamSink { + /** Stable id used by clients to subscribe. */ + readonly id: string + /** + * Aborts when the consumer cancels (server-side) or when the transport + * loses every subscriber. Producers should poll `signal.aborted` and exit + * cleanly. + */ + readonly signal: AbortSignal + /** `true` after `close()` / `error()`. Further writes throw. */ + readonly closed: boolean + /** Last allocated sequence number. `0` until the first write. */ + readonly lastSeq: number + + write: (chunk: T) => void + error: (reason: unknown) => void + close: () => void + /** External-cancel path. Aborts the signal so handlers can short-circuit. */ + abort: (reason?: unknown) => void + + /** `WritableStream` adapter — same in-memory state as the imperative API. */ + readonly writable: WritableStream + + /** + * Internal — RPC layer subscribes to receive chunk/end notifications. + * Not part of the public contract; do not call directly. + * + * @internal + */ + readonly events: EventEmitter> + + /** + * Internal — replay buffer. RPC layer reads on (re)subscribe to feed + * missed chunks before going live. + * + * @internal + */ + readonly buffer: ReadonlyArray> +} + +export interface CreateStreamReaderOptions { + id?: string + /** + * Maximum number of buffered chunks held client-side while the consumer + * isn't draining. On overflow, the oldest chunk is dropped. + */ + highWaterMark?: number + /** + * Called when the chunk queue overflows the high-water mark. The RPC + * layer wires this to a coded warning; the primitive itself is + * RPC-agnostic. + */ + onOverflow?: (dropped: number) => void + /** Called when the consumer cancels — the RPC layer sends `:cancel` upstream. */ + onCancel?: () => void +} + +/** + * Client-side consumer handle. Both an `AsyncIterable` (for `for await`) + * and exposes `readable: ReadableStream` (for `pipeTo`). Pick one — they + * share a single internal queue, so concurrent draining will race. + */ +export interface StreamReader extends AsyncIterable { + readonly id: string + readonly cancelled: boolean + readonly done: boolean + /** Highest `seq` observed. Used for replay on reconnect. */ + readonly lastSeenSeq: number + /** `ReadableStream` adapter for `pipeTo`-style consumption. */ + readonly readable: ReadableStream + + cancel: () => void + + /** @internal */ + _push: (seq: number, chunk: T) => void + /** @internal */ + _end: (error?: StreamErrorPayload) => void +} + +const DEFAULT_HIGH_WATER_MARK = 256 + +class StreamClosedError extends Error { + override name = 'StreamClosedError' +} + +/** + * Build a server-side stream sink. RPC-agnostic — the RPC host wires + * `events.on('chunk' | 'end')` to broadcast, and reads `buffer` to replay + * for late or reconnecting subscribers. + */ +export function createStreamSink(options: CreateStreamSinkOptions = {}): StreamSink { + const id = options.id ?? nanoid() + const replayWindow = Math.max(0, options.replayWindow ?? 0) + const events = createEventEmitter>() + const controller = new AbortController() + const buffer: BufferedChunk[] = [] + + let closed = false + let lastSeq = 0 + + function write(chunk: T): void { + if (closed) { + throw new StreamClosedError(`Cannot write to a closed stream "${id}"`) + } + lastSeq += 1 + if (replayWindow > 0) { + buffer.push({ seq: lastSeq, chunk }) + if (buffer.length > replayWindow) + buffer.splice(0, buffer.length - replayWindow) + } + events.emit('chunk', lastSeq, chunk) + } + + function error(reason: unknown): void { + if (closed) + return + closed = true + const payload = toErrorPayload(reason) + controller.abort(reason) + events.emit('end', payload) + } + + function close(): void { + if (closed) + return + closed = true + if (!controller.signal.aborted) + controller.abort('stream closed') + events.emit('end', undefined) + } + + function abort(reason?: unknown): void { + if (closed) + return + if (!controller.signal.aborted) + controller.abort(reason ?? 'aborted') + } + + const writable = new WritableStream({ + write(chunk) { + write(chunk) + }, + close() { + close() + }, + abort(reason) { + error(reason) + }, + }) + + return { + id, + signal: controller.signal, + get closed() { return closed }, + get lastSeq() { return lastSeq }, + write, + error, + close, + abort, + writable, + events, + buffer, + } +} + +/** + * Build a client-side stream reader. RPC-agnostic — the RPC host calls + * `_push(seq, chunk)` on each incoming chunk and `_end(error?)` on the + * terminal frame. Consumers iterate with `for await` or pipe `readable`. + */ +export function createStreamReader(options: CreateStreamReaderOptions = {}): StreamReader { + const id = options.id ?? nanoid() + const highWaterMark = Math.max(1, options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK) + + const queue: T[] = [] + let lastSeenSeq = 0 + let done = false + let cancelled = false + let endError: StreamErrorPayload | undefined + let pending: { resolve: (r: IteratorResult) => void, reject: (err: unknown) => void } | undefined + // Lazily created — accessing `reader.readable` claims the queue for + // `pipeTo` consumption. While inactive, `_push` only feeds the + // AsyncIterator path. Mixing the two surfaces is undefined behavior. + let pullController: ReadableStreamDefaultController | undefined + let readableInstance: ReadableStream | undefined + + function drainNext(): void { + if (!pending) + return + if (queue.length > 0) { + const value = queue.shift()! + const r = pending + pending = undefined + r.resolve({ value, done: false }) + return + } + if (done) { + const r = pending + pending = undefined + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + r.reject(err) + } + else { + r.resolve({ value: undefined as unknown as T, done: true }) + } + } + } + + function feedReadable(): void { + if (!pullController) + return + while (queue.length > 0) { + const v = queue.shift()! + try { + pullController.enqueue(v) + } + catch { + // controller closed/errored under us — drop silently; the + // ReadableStream consumer is gone and we can't push further. + break + } + } + if (done && pullController) { + try { + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + pullController.error(err) + } + else { + pullController.close() + } + } + catch { + // already closed + } + pullController = undefined + } + } + + function push(seq: number, chunk: T): void { + if (done || cancelled) + return + if (seq <= lastSeenSeq) + return // dedupe replays we've already seen + lastSeenSeq = seq + queue.push(chunk) + if (queue.length > highWaterMark) { + const overflow = queue.length - highWaterMark + queue.splice(0, overflow) + options.onOverflow?.(overflow) + } + drainNext() + if (readableInstance) + feedReadable() + } + + function end(error?: StreamErrorPayload): void { + if (done) + return + done = true + endError = error + drainNext() + if (readableInstance) + feedReadable() + } + + function cancel(): void { + if (cancelled || done) + return + cancelled = true + options.onCancel?.() + end(undefined) + } + + function getReadable(): ReadableStream { + if (readableInstance) + return readableInstance + readableInstance = new ReadableStream({ + start(controller) { + pullController = controller + feedReadable() + }, + cancel() { + cancel() + }, + }) + return readableInstance + } + + const reader: StreamReader = { + id, + get cancelled() { return cancelled }, + get done() { return done }, + get lastSeenSeq() { return lastSeenSeq }, + get readable() { return getReadable() }, + cancel, + _push: push, + _end: end, + [Symbol.asyncIterator](): AsyncIterator { + return { + next(): Promise> { + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift()!, done: false }) + } + if (done) { + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + return Promise.reject(err) + } + return Promise.resolve({ value: undefined as unknown as T, done: true }) + } + return new Promise>((resolve, reject) => { + pending = { resolve, reject } + }) + }, + return(): Promise> { + cancel() + return Promise.resolve({ value: undefined as unknown as T, done: true }) + }, + } + }, + } + + return reader +} + +function toErrorPayload(reason: unknown): StreamErrorPayload { + if (reason instanceof Error) { + return { name: reason.name || 'Error', message: reason.message } + } + if (typeof reason === 'string') { + return { name: 'Error', message: reason } + } + try { + return { name: 'Error', message: JSON.stringify(reason) } + } + catch { + return { name: 'Error', message: String(reason) } + } +} diff --git a/devframe/packages/devframe/tsdown.config.ts b/devframe/packages/devframe/tsdown.config.ts index a95b2abb..29a262f8 100644 --- a/devframe/packages/devframe/tsdown.config.ts +++ b/devframe/packages/devframe/tsdown.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ 'utils/promise': 'src/utils/promise.ts', 'utils/shared-state': 'src/utils/shared-state.ts', 'utils/state': 'src/utils/state.ts', + 'utils/streaming-channel': 'src/utils/streaming-channel.ts', 'utils/when': 'src/utils/when.ts', 'adapters/cli': 'src/adapters/cli.ts', 'adapters/dev': 'src/adapters/dev.ts', diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 7fc6034a..261871c6 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -23,6 +23,7 @@ export interface DevToolsRpcClient { callOptional: DevToolsRpcClientCallOptional; client: DevToolsClientRpcHost; sharedState: RpcSharedStateHost; + streaming: RpcStreamingClientHost; cacheManager: RpcCacheManager; } export interface DevToolsRpcClientMode { @@ -98,6 +99,13 @@ export interface DocksPanelContext { export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; } +export interface RpcStreamingClientHost { + subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; + upload: (_: string, _: string) => StreamSink; +} +export interface StreamingSubscribeOptions { + highWaterMark?: number; +} export interface WhenClauseContext { readonly context: WhenContext; } @@ -114,6 +122,7 @@ export type DockClientType = 'embedded' | 'standalone'; // #region Functions export declare function connectDevtool(_?: DevToolsRpcClientOptions): Promise; +export declare function createRpcStreamingClientHost(_: DevToolsRpcClient): RpcStreamingClientHost; export declare function getDevToolsClientContext(): DevToolsClientContext | undefined; export declare function getDevToolsRpcClient(_?: DevToolsRpcClientOptions): Promise; // #endregion diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index a443e6df..a591d770 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -4,6 +4,7 @@ // #region Other export { CLIENT_CONTEXT_KEY } export { getDevToolsRpcClient as connectDevtool } +export { createRpcStreamingClientHost } export { getDevToolsClientContext } export { getDevToolsRpcClient } // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index ba549f25..d4b4787a 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -83,7 +83,6 @@ export { DevToolsServerCommandInput } export { DevToolsTerminalHost } export { DevToolsTerminalSession } export { DevToolsTerminalSessionBase } -export { DevToolsTerminalSessionStreamChunkEvent } export { DevToolsTerminalStatus } export { DevToolsViewAction } export { DevToolsViewBuiltin } @@ -108,5 +107,8 @@ export { RpcFunctionAgentOptions } export { RpcFunctionsHost } export { RpcSharedStateGetOptions } export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } export { Thenable } // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 0f476b64..0f787795 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -178,7 +178,9 @@ export declare class DevToolsTerminalHost implements DevToolsTerminalHost$1 { readonly sessions: DevToolsTerminalHost$1['sessions']; readonly events: DevToolsTerminalHost$1['events']; private _boundStreams; + private _channel?; constructor(_: DevToolsNodeContext); + private getStreamingChannel; register(_: DevToolsTerminalSession): DevToolsTerminalSession; update(_: PartialWithoutId): void; remove(_: DevToolsTerminalSession): void; @@ -199,6 +201,8 @@ export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase; constructor(_: DevToolsNodeContext); sharedState: RpcSharedStateHost; + streaming: RpcStreamingHost; + _emitSessionDisconnected(_: DevToolsNodeRpcSessionMeta): void; invokeLocal>(_: T, ..._: Args): Promise>>; broadcast>(_: RpcBroadcastOptions): Promise; getCurrentRpcSession(): DevToolsNodeRpcSession | undefined; @@ -212,6 +216,7 @@ export declare function consumeTempAuthToken(_: string, _: SharedState; export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): RpcSharedStateHost; +export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost; export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function getInternalContext(_: DevToolsNodeContext): DevToolsInternalContext; export declare function getPendingAuth(): PendingAuthRequest | null; diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index 07fa5bb9..bb7a0b84 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -18,6 +18,7 @@ export { ContextUtils } export { createH3DevToolsHost } export { createHostContext } export { createRpcSharedStateServerHost } +export { createRpcStreamingServerHost } export { createStorage } export { DevToolsAgentHost } export { DevToolsCommandsHost } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 7b19925e..9639c657 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -74,7 +74,6 @@ export { DevToolsServerCommandInput } export { DevToolsTerminalHost } export { DevToolsTerminalSession } export { DevToolsTerminalSessionBase } -export { DevToolsTerminalSessionStreamChunkEvent } export { DevToolsTerminalStatus } export { DevToolsViewAction } export { DevToolsViewBuiltin } @@ -99,5 +98,8 @@ export { RpcFunctionAgentOptions } export { RpcFunctionsHost } export { RpcSharedStateGetOptions } export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } export { Thenable } // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts new file mode 100644 index 00000000..b733214d --- /dev/null +++ b/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/streaming-channel` + */ +// #region Other +export { BufferedChunk } +export { createStreamReader } +export { CreateStreamReaderOptions } +export { createStreamSink } +export { CreateStreamSinkOptions } +export { StreamErrorPayload } +export { StreamReader } +export { StreamSink } +export { StreamSinkEvents } +// #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js new file mode 100644 index 00000000..da8f4823 --- /dev/null +++ b/devframe/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/streaming-channel` + */ +// #region Functions +export function createStreamReader(_) {} +export function createStreamSink(_) {} +// #endregion \ No newline at end of file diff --git a/packages/core/src/client/webcomponents/state/terminals.ts b/packages/core/src/client/webcomponents/state/terminals.ts index 28a2ea0e..413834de 100644 --- a/packages/core/src/client/webcomponents/state/terminals.ts +++ b/packages/core/src/client/webcomponents/state/terminals.ts @@ -1,9 +1,11 @@ -import type { DevToolsRpcClientFunctions, DevToolsTerminalSessionBase, DevToolsTerminalSessionStreamChunkEvent } from '@vitejs/devtools-kit' +import type { DevToolsRpcClientFunctions, DevToolsTerminalSessionBase } from '@vitejs/devtools-kit' import type { DocksContext } from '@vitejs/devtools-kit/client' import type { Terminal } from '@xterm/xterm' import type { Reactive } from 'vue' import { reactive } from 'vue' +const TERMINAL_STREAM_CHANNEL = 'devtoolskit:internal:terminals' + export interface TerminalState { info: DevToolsTerminalSessionBase buffer: string[] | null @@ -16,6 +18,29 @@ export function useTerminals(context: DocksContext): Reactive> = _terminalsMap = reactive(new Map()) + const subscribed = new Set() + + function subscribeToStream(id: string): void { + if (subscribed.has(id)) + return + subscribed.add(id) + const reader = context.rpc.streaming.subscribe(TERMINAL_STREAM_CHANNEL, id) + ;(async () => { + try { + for await (const chunk of reader) { + const terminal = map.get(id) + if (!terminal) + continue + terminal.buffer?.push(chunk) + terminal.terminal?.writeln(chunk) + } + } + catch (err) { + console.warn(`[VITE DEVTOOLS] Terminal stream "${id}" ended with error:`, err) + } + })() + } + async function updateTerminals() { const terminals = await context.rpc.call('devtoolskit:internal:terminals:list') @@ -29,6 +54,7 @@ export function useTerminals(context: DocksContext): Reactive updateTerminals(), }) - context.rpc.client.register({ - name: 'devtoolskit:internal:terminals:stream-chunk' satisfies keyof DevToolsRpcClientFunctions, - type: 'action', - handler: (data: DevToolsTerminalSessionStreamChunkEvent) => { - const terminal = map.get(data.id) - if (!terminal) { - console.warn(`[VITE DEVTOOLS] Terminal with id "${data.id}" not found`) - return - } - terminal.buffer?.push(...data.chunks) - for (const chunk of data.chunks) - terminal.terminal?.writeln(chunk) - }, - }) updateTerminals() return map diff --git a/packages/core/src/node/rpc/index.ts b/packages/core/src/node/rpc/index.ts index c64383db..f41960fb 100644 --- a/packages/core/src/node/rpc/index.ts +++ b/packages/core/src/node/rpc/index.ts @@ -1,4 +1,4 @@ -import type { DevToolsDockEntry, DevToolsDocksUserSettings, DevToolsServerCommandEntry, DevToolsTerminalSessionStreamChunkEvent, RpcDefinitionsFilter, RpcDefinitionsToFunctions } from '@vitejs/devtools-kit' +import type { DevToolsDockEntry, DevToolsDocksUserSettings, DevToolsServerCommandEntry, RpcDefinitionsFilter, RpcDefinitionsToFunctions } from '@vitejs/devtools-kit' import type { SharedStatePatch } from 'devframe/utils/shared-state' import { anonymousAuth } from './anonymous/auth' import { commandsExecute } from './internal/commands-execute' @@ -74,7 +74,6 @@ declare module '@vitejs/devtools-kit' { '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 - 'devtoolskit:internal:terminals:stream-chunk': (data: DevToolsTerminalSessionStreamChunkEvent) => Promise 'devtoolskit:internal:terminals:updated': () => Promise } diff --git a/packages/core/src/node/ws.ts b/packages/core/src/node/ws.ts index 0aa0ac8d..25f84bef 100644 --- a/packages/core/src/node/ws.ts +++ b/packages/core/src/node/ws.ts @@ -140,6 +140,7 @@ export async function createWsServer(options: CreateWsServerOptions) { }, onDisconnected: (ws, meta) => { wsClients.delete(ws) + rpcHost._emitSessionDisconnected(meta) console.log(c.red`${MARK_INFO} Websocket client disconnected. [${meta.id}]`) }, }) diff --git a/packages/kit/src/types/index.ts b/packages/kit/src/types/index.ts index 5bf57941..6428a5e4 100644 --- a/packages/kit/src/types/index.ts +++ b/packages/kit/src/types/index.ts @@ -63,7 +63,6 @@ export type { DevToolsTerminalHost, DevToolsTerminalSession, DevToolsTerminalSessionBase, - DevToolsTerminalSessionStreamChunkEvent, DevToolsTerminalStatus, DevToolsViewAction, DevToolsViewBuiltin, @@ -87,5 +86,8 @@ export type { RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost, + RpcStreamingChannel, + RpcStreamingChannelOptions, + RpcStreamingHost, Thenable, } from 'devframe/types' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32405dd9..fd6f3f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,7 +474,7 @@ importers: version: 0.13.0(vue@3.5.33(typescript@6.0.3))(zod@4.3.6) '@nuxt/devtools': specifier: ^3.2.4 - version: 3.2.4(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + version: 3.2.4(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) '@nuxt/eslint': specifier: catalog:devtools version: 1.15.2(@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(typescript@6.0.3)(vite@8.0.10) @@ -534,7 +534,7 @@ importers: version: 1.0.2 nuxt: specifier: ^4.4.4 - version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) p-limit: specifier: catalog:deps version: 7.3.0 @@ -546,7 +546,7 @@ importers: version: 1.1.1 tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) tsnapi: specifier: catalog:testing version: 0.3.2(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@8.0.10)) @@ -567,7 +567,7 @@ importers: version: 1.17.5(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1) vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-inspect: specifier: catalog:devtools version: 12.0.0-beta.1(@nuxt/kit@4.4.4(magicast@0.5.2))(typescript@6.0.3)(vite@8.0.10) @@ -594,10 +594,10 @@ importers: version: 11.14.0 vitepress: specifier: catalog:docs - version: 2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) + version: 2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) vitepress-plugin-mermaid: specifier: catalog:docs - version: 2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) + version: 2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) devframe/examples/devframe-counter: dependencies: @@ -631,7 +631,38 @@ importers: version: 3.0.2 vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: catalog:testing + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@8.0.10) + ws: + specifier: catalog:deps + version: 8.20.0 + + devframe/examples/devframe-streaming-chat: + dependencies: + devframe: + specifier: workspace:* + version: link:../../packages/devframe + preact: + specifier: catalog:frontend + version: 10.29.1 + devDependencies: + '@preact/preset-vite': + specifier: catalog:build + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@8.0.10) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 1.15.11 + sirv: + specifier: catalog:deps + version: 3.0.2 + vite: + specifier: ^8.0.10 + version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: catalog:testing version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@8.0.10) @@ -689,7 +720,7 @@ importers: version: 2.13.2 tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) whenexpr: specifier: catalog:deps version: 0.1.2 @@ -894,16 +925,16 @@ importers: version: 4.1.3 tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) typescript: specifier: catalog:devtools version: 6.0.3 unplugin-vue: specifier: catalog:build - version: 7.2.0(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3) + version: 7.2.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3) vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue-router: specifier: catalog:playground version: 5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) @@ -934,13 +965,13 @@ importers: version: 11.1.4 tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) ua-parser-modern: specifier: catalog:frontend version: 0.1.1 vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) packages/rolldown: dependencies: @@ -1043,7 +1074,7 @@ importers: version: 14.2.1(vue@3.5.33(typescript@6.0.3)) '@vueuse/nuxt': specifier: catalog:build - version: 14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) + version: 14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) '@vueuse/router': specifier: catalog:frontend version: 14.2.1(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) @@ -1082,7 +1113,7 @@ importers: version: 1.0.0 tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) unocss: specifier: catalog:build version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@unocss/webpack@66.6.8(webpack@5.104.1(esbuild@0.28.0)))(vite@8.0.10) @@ -1140,7 +1171,7 @@ importers: version: 14.2.1(vue@3.5.33(typescript@6.0.3)) nuxt: specifier: ^4.4.4 - version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) unocss: specifier: '*' version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@unocss/webpack@66.6.8(webpack@5.104.1(esbuild@0.28.0)))(vite@8.0.10) @@ -1201,13 +1232,13 @@ importers: version: 14.2.1(vue@3.5.33(typescript@6.0.3)) '@vueuse/nuxt': specifier: catalog:build - version: 14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) + version: 14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) floating-vue: specifier: catalog:frontend version: 5.2.2(@nuxt/kit@4.4.4(magicast@0.5.2))(vue@3.5.33(typescript@6.0.3)) tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) unocss: specifier: catalog:build version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@unocss/webpack@66.6.8(webpack@5.104.1(esbuild@0.28.0)))(vite@8.0.10) @@ -1220,7 +1251,7 @@ importers: devDependencies: tsdown: specifier: catalog:build - version: 0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) + version: 0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)) tsx: specifier: catalog:build version: 4.21.0 @@ -4105,14 +4136,19 @@ packages: peerDependencies: vite: ^8.0.10 - '@vitejs/devtools-rolldown@0.1.15': - resolution: {integrity: sha512-PYojc9gQrd9bszNv+t20ocDG1zcrdryPA9XyxlZOO9EHbz+W50IW+22y+9+u8cat39cHsYuuChF6WqOYrM5hpg==} + '@vitejs/devtools-kit@0.1.18': + resolution: {integrity: sha512-vjwEd8wcBFP3cki2NUlNEktf3+AC4cG41GLVyBMnzdwDK+FbZnAesAPt1UWgm2F2FoSfYUUFAQz3/nxOT8Eoyg==} + peerDependencies: + vite: ^8.0.10 + + '@vitejs/devtools-rolldown@0.1.18': + resolution: {integrity: sha512-wA6jiKfAw1qOH0Jj4YCzH1xaY2x4VHTle3oiNcpdUK9EXHYyGdsS6PlQfkMz26FBOv+lR6w679Ce53k1DVYE4w==} '@vitejs/devtools-rpc@0.1.15': resolution: {integrity: sha512-pHDz3bcK0dlpLzI2ve2Xwnnx6iSASRMuEFJDbe64LAZJPVCBW/Pb0IeEpodI58O9xVpB0EBZykZftz8/oTeVtQ==} - '@vitejs/devtools@0.1.15': - resolution: {integrity: sha512-LKE2HgsRMR25ordyXEjXCILO/IOrtHDzBc4Vzfg+ntvR8lF09I0XIX73GS7LQHO+Bzfpb0m3PrgnyThyaa2J0Q==} + '@vitejs/devtools@0.1.18': + resolution: {integrity: sha512-/XPGlE9EiWaHLe3AhMno9RipOTmltK5pIIoPLIk9Ekzs+74xFa2lwoeT9oKS1K2CMozd8jL61/qmFUlPtsIVHQ==} hasBin: true peerDependencies: vite: ^8.0.10 @@ -5259,6 +5295,20 @@ packages: devalue@5.7.1: resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} + devframe@0.1.18: + resolution: {integrity: sha512-EHfBM4ew12HYvGtQ2GDubzRzEVvoZqFu9fKjyzV66RDFyroOBYO8Pjyksa9O78GhkXnoUDH1smwrQ2kYziH1Yg==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.0.0 + '@nuxt/kit': ^4.4.4 + launch-editor: ^2.0.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@nuxt/kit': + optional: true + launch-editor: + optional: true + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -8730,11 +8780,6 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue-virtual-scroller@2.0.1: - resolution: {integrity: sha512-3Drq8C61C4B3reSaZJr5nXBf/B7Beq1+h5/kYZB25MLYljTy97ISeUufRX9z6ZSZlFDXyafAOLK9XwajOWJY1A==} - peerDependencies: - vue: ^3.3.0 - vue-virtual-scroller@3.0.0: resolution: {integrity: sha512-k42ZDpeWD3An7MWYrOw8Xry1X52oxFH5ZknIeR5sbOJSDuUPNgUgSb/8lzPfoRXIBBpjozTLNvdQPbYkgbvXew==} peerDependencies: @@ -9812,7 +9857,7 @@ snapshots: dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) execa: 8.0.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - magicast @@ -9827,7 +9872,7 @@ snapshots: pkg-types: 2.3.0 semver: 7.7.4 - '@nuxt/devtools@3.2.4(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': + '@nuxt/devtools@3.2.4(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': dependencies: '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@8.0.10) '@nuxt/devtools-wizard': 3.2.4 @@ -9857,13 +9902,56 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.16 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.4(magicast@0.5.2))(vite@8.0.10) vite-plugin-vue-tracer: 1.3.0(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) which: 6.0.1 ws: 8.20.0 optionalDependencies: - '@vitejs/devtools': 0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) + '@vitejs/devtools': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + - vue + + '@nuxt/devtools@3.2.4(@vitejs/devtools@0.1.18)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@8.0.10) + '@nuxt/devtools-wizard': 3.2.4 + '@nuxt/kit': 4.4.4(magicast@0.5.2) + '@vue/devtools-core': 8.1.0(vue@3.5.33(typescript@6.0.3)) + '@vue/devtools-kit': 8.1.0 + birpc: 4.0.0 + consola: 3.4.2 + destr: 2.0.5 + error-stack-parser-es: 1.0.5 + execa: 8.0.1 + fast-npm-meta: 1.4.2 + get-port-please: 3.2.0 + hookable: 6.1.1 + image-meta: 0.2.2 + is-installed-globally: 1.0.0 + launch-editor: 2.13.2 + local-pkg: 1.1.2 + magicast: 0.5.2 + nypm: 0.6.5 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + semver: 7.7.4 + simple-git: 3.33.0 + sirv: 3.0.2 + structured-clone-es: 2.0.0 + tinyglobby: 0.2.16 + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.4(magicast@0.5.2))(vite@8.0.10) + vite-plugin-vue-tracer: 1.3.0(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + which: 6.0.1 + ws: 8.20.0 + optionalDependencies: + '@vitejs/devtools': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) transitivePeerDependencies: - bufferutil - supports-color @@ -10006,7 +10094,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3)': + '@nuxt/nitro-server@4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@nuxt/devalue': 2.0.2 @@ -10025,7 +10113,75 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(idb-keyval@6.2.2)(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18) - nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + nypm: 0.6.6 + ohash: 2.0.11 + pathe: 2.0.3 + rou3: 0.8.1 + std-env: 4.1.0 + ufo: 1.6.4 + unctx: 2.5.0 + unstorage: 1.17.5(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1) + vue: 3.5.33(typescript@6.0.3) + vue-bundle-renderer: 2.2.0 + vue-devtools-stub: 0.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@babel/core' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - db0 + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - magicast + - mysql2 + - oxc-parser + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - typescript + - uploadthing + - xml2js + + '@nuxt/nitro-server@4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3)': + dependencies: + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@nuxt/devalue': 2.0.2 + '@nuxt/kit': 4.4.4(magicast@0.5.2) + '@unhead/vue': 2.1.13(vue@3.5.33(typescript@6.0.3)) + '@vue/shared': 3.5.33 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + devalue: 5.7.1 + errx: 0.1.0 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + h3: 1.15.11 + impound: 1.1.5 + klona: 2.0.6 + mocked-exports: 0.1.1 + nitropack: 2.13.4(idb-keyval@6.2.2)(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18) + nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) nypm: 0.6.6 ohash: 2.0.11 pathe: 2.0.3 @@ -10221,7 +10377,69 @@ snapshots: - vue-tsc - yaml - '@nuxt/vite-builder@4.4.4(5288520beab7cd6fc26bc8d071490933)': + '@nuxt/vite-builder@4.4.4(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3))(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3)': + dependencies: + '@nuxt/kit': 4.4.4(magicast@0.5.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) + '@vitejs/plugin-vue': 6.0.6(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': 5.1.5(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + autoprefixer: 10.5.0(postcss@8.5.12) + consola: 3.4.2 + cssnano: 7.1.7(postcss@8.5.12) + defu: 6.1.7 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + get-port-please: 3.2.0 + jiti: 2.6.1 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + mocked-exports: 0.1.1 + nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + nypm: 0.6.6 + pathe: 2.0.3 + pkg-types: 2.3.1 + postcss: 8.5.12 + seroval: 1.5.2 + std-env: 4.1.0 + ufo: 1.6.4 + unenv: 2.0.0-rc.24 + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-checker: 0.13.0(eslint@10.2.1(jiti@2.6.1))(optionator@0.9.4)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + vue-bundle-renderer: 2.2.0 + optionalDependencies: + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + rolldown: 1.0.0-rc.18 + rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2) + transitivePeerDependencies: + - '@biomejs/biome' + - '@types/node' + - '@vitejs/devtools' + - esbuild + - eslint + - less + - magicast + - meow + - optionator + - oxlint + - rollup + - sass + - sass-embedded + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - vls + - vti + - vue-tsc + - yaml + + '@nuxt/vite-builder@4.4.4(a6274ef9135f33fd87cf785a36842dc0)': dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) @@ -10239,7 +10457,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) nypm: 0.6.6 pathe: 2.0.3 pkg-types: 2.3.1 @@ -10248,8 +10466,8 @@ snapshots: std-env: 4.1.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-checker: 0.13.0(eslint@10.2.1(jiti@2.6.1))(optionator@0.9.4)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3)) vue: 3.5.33(typescript@6.0.3) vue-bundle-renderer: 2.2.0 @@ -10774,7 +10992,7 @@ snapshots: debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-prerender-plugin: 0.5.13(vite@8.0.10) zimmerframe: 1.1.4 transitivePeerDependencies: @@ -10798,7 +11016,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -11540,8 +11758,8 @@ snapshots: '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: @@ -11964,7 +12182,7 @@ snapshots: pathe: 2.0.3 tinyglobby: 0.2.16 unplugin-utils: 0.3.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) '@unocss/webpack@66.6.8(webpack@5.104.1(esbuild@0.28.0))': dependencies: @@ -12079,23 +12297,39 @@ snapshots: '@vitejs/devtools-rpc': 0.1.15(typescript@6.0.3) birpc: 4.0.0 ohash: 2.0.11 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@vitejs/devtools-kit@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3)(vite@8.0.10)': + dependencies: + birpc: 4.0.0 + devframe: 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3) + ohash: 2.0.11 + sirv: 3.0.2 + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@nuxt/kit' - bufferutil + - launch-editor - typescript - utf-8-validate + optional: true - '@vitejs/devtools-rolldown@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': + '@vitejs/devtools-rolldown@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(launch-editor@2.13.2)(typescript@6.0.3)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': dependencies: '@floating-ui/dom': 1.7.6 '@pnpm/read-project-manifest': 1001.2.6(@pnpm/logger@1001.0.1) '@rolldown/debug': 1.0.0-rc.18 - '@vitejs/devtools-kit': 0.1.15(typescript@6.0.3)(vite@8.0.10) - '@vitejs/devtools-rpc': 0.1.15(typescript@6.0.3) + '@vitejs/devtools-kit': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3)(vite@8.0.10) ansis: 4.2.0 birpc: 4.0.0 cac: 7.0.0 d3-shape: 3.2.0 + devframe: 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3) diff: 9.0.0 get-port-please: 3.2.0 h3: 1.15.11 @@ -12112,7 +12346,7 @@ snapshots: tinyglobby: 0.2.16 unconfig: 7.5.0 unstorage: 1.17.5(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1) - vue-virtual-scroller: 2.0.1(vue@3.5.33(typescript@6.0.3)) + vue-virtual-scroller: 3.0.2(vue@3.5.33(typescript@6.0.3)) ws: 8.20.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -12123,7 +12357,9 @@ snapshots: - '@azure/storage-blob' - '@capacitor/preferences' - '@deno/kv' + - '@modelcontextprotocol/sdk' - '@netlify/blobs' + - '@nuxt/kit' - '@planetscale/database' - '@pnpm/logger' - '@upstash/redis' @@ -12135,6 +12371,7 @@ snapshots: - db0 - idb-keyval - ioredis + - launch-editor - typescript - uploadthing - utf-8-validate @@ -12156,13 +12393,13 @@ snapshots: - typescript - utf-8-validate - '@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10)': + '@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10)': dependencies: - '@vitejs/devtools-kit': 0.1.15(typescript@6.0.3)(vite@8.0.10) - '@vitejs/devtools-rolldown': 0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) - '@vitejs/devtools-rpc': 0.1.15(typescript@6.0.3) + '@vitejs/devtools-kit': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3)(vite@8.0.10) + '@vitejs/devtools-rolldown': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(launch-editor@2.13.2)(typescript@6.0.3)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) birpc: 4.0.0 cac: 7.0.0 + devframe: 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3) h3: 1.15.11 immer: 11.1.4 launch-editor: 2.13.2 @@ -12173,8 +12410,8 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 sirv: 3.0.2 - tinyexec: 1.1.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + tinyexec: 1.1.2 + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) ws: 8.20.0 transitivePeerDependencies: @@ -12186,7 +12423,9 @@ snapshots: - '@azure/storage-blob' - '@capacitor/preferences' - '@deno/kv' + - '@modelcontextprotocol/sdk' - '@netlify/blobs' + - '@nuxt/kit' - '@planetscale/database' - '@pnpm/logger' - '@upstash/redis' @@ -12222,7 +12461,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-rc.17 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -12236,7 +12475,7 @@ snapshots: '@vitejs/plugin-vue@6.0.6(vite@8.0.10)(vue@3.5.33(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@8.0.10))': @@ -12266,7 +12505,7 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.5': dependencies: @@ -12507,13 +12746,13 @@ snapshots: '@vueuse/metadata@14.2.1': {} - '@vueuse/nuxt@14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))': + '@vueuse/nuxt@14.2.1(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))': dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) '@vueuse/core': 14.2.1(vue@3.5.33(typescript@6.0.3)) '@vueuse/metadata': 14.2.1 local-pkg: 1.1.2 - nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) + nuxt: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) transitivePeerDependencies: - magicast @@ -13464,6 +13703,30 @@ snapshots: devalue@5.7.1: {} + devframe@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(launch-editor@2.13.2)(typescript@6.0.3): + dependencies: + '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.3)) + ansis: 4.2.0 + birpc: 4.0.0 + cac: 7.0.0 + h3: 1.15.11 + logs-sdk: 0.0.6 + ohash: 2.0.11 + pathe: 2.0.3 + sirv: 3.0.2 + structured-clone-es: 2.0.0 + valibot: 1.3.1(typescript@6.0.3) + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + '@nuxt/kit': 4.4.4(magicast@0.5.2) + launch-editor: 2.13.2 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -15458,16 +15721,144 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3): + nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.2)(typescript@6.0.3) '@nuxt/cli': 3.35.1(@nuxt/schema@4.4.4)(cac@7.0.0)(magicast@0.5.2) - '@nuxt/devtools': 3.2.4(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + '@nuxt/devtools': 3.2.4(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) '@nuxt/kit': 4.4.4(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3) + '@nuxt/nitro-server': 4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3) '@nuxt/schema': 4.4.4 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.4(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.4(5288520beab7cd6fc26bc8d071490933) + '@nuxt/vite-builder': 4.4.4(a6274ef9135f33fd87cf785a36842dc0) + '@unhead/vue': 2.1.13(vue@3.5.33(typescript@6.0.3)) + '@vue/shared': 3.5.33 + chokidar: 5.0.0 + compatx: 0.2.0 + consola: 3.4.2 + cookie-es: 2.0.1 + defu: 6.1.7 + devalue: 5.7.1 + errx: 0.1.0 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + hookable: 6.1.1 + ignore: 7.0.5 + impound: 1.1.5 + jiti: 2.6.1 + klona: 2.0.6 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + nanotar: 0.3.0 + nypm: 0.6.6 + ofetch: 1.5.1 + ohash: 2.0.11 + on-change: 6.0.2 + oxc-minify: 0.128.0 + oxc-parser: 0.128.0 + oxc-transform: 0.128.0 + oxc-walker: 0.7.0(oxc-parser@0.128.0) + pathe: 2.0.3 + perfect-debounce: 2.1.0 + picomatch: 4.0.4 + pkg-types: 2.3.1 + rou3: 0.8.1 + scule: 1.3.0 + semver: 7.7.4 + std-env: 4.1.0 + tinyglobby: 0.2.16 + ufo: 1.6.4 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unimport: 6.2.0(oxc-parser@0.128.0) + unplugin: 3.0.0 + unrouting: 0.1.7 + untyped: 2.0.0 + vue: 3.5.33(typescript@6.0.3) + vue-router: 5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) + optionalDependencies: + '@parcel/watcher': 2.5.6 + '@types/node': 25.0.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@babel/core' + - '@babel/plugin-proposal-decorators' + - '@babel/plugin-syntax-jsx' + - '@biomejs/biome' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@pinia/colada' + - '@planetscale/database' + - '@rollup/plugin-babel' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - '@vue/compiler-sfc' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - bufferutil + - cac + - commander + - db0 + - drizzle-orm + - encoding + - esbuild + - eslint + - idb-keyval + - ioredis + - less + - magicast + - meow + - mysql2 + - optionator + - oxlint + - pinia + - react-native-b4a + - rolldown + - rollup + - rollup-plugin-visualizer + - sass + - sass-embedded + - sqlite3 + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - utf-8-validate + - vite + - vls + - vti + - vue-tsc + - xml2js + - yaml + + nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3): + dependencies: + '@dxup/nuxt': 0.4.1(magicast@0.5.2)(typescript@6.0.3) + '@nuxt/cli': 3.35.1(@nuxt/schema@4.4.4)(cac@7.0.0)(magicast@0.5.2) + '@nuxt/devtools': 3.2.4(@vitejs/devtools@0.1.18)(vite@8.0.10)(vue@3.5.33(typescript@6.0.3)) + '@nuxt/kit': 4.4.4(magicast@0.5.2) + '@nuxt/nitro-server': 4.4.4(@babel/core@7.29.0)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(oxc-parser@0.128.0)(rolldown@1.0.0-rc.18)(typescript@6.0.3) + '@nuxt/schema': 4.4.4 + '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.4(magicast@0.5.2)) + '@nuxt/vite-builder': 4.4.4(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(nuxt@4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.0)(eslint@10.2.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10)(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3))(optionator@0.9.4)(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3))(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3) '@unhead/vue': 2.1.13(vue@3.5.33(typescript@6.0.3)) '@vue/shared': 3.5.33 chokidar: 5.0.0 @@ -17002,7 +17393,7 @@ snapshots: ts-dedent@2.2.0: {} - tsdown@0.21.10(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)): + tsdown@0.21.10(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(publint@0.3.18)(synckit@0.11.12)(typescript@6.0.3)(vue-tsc@3.2.7(typescript@6.0.3)): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -17021,7 +17412,7 @@ snapshots: unconfig-core: 7.5.0 unrun: 0.2.37(synckit@0.11.12) optionalDependencies: - '@vitejs/devtools': 0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) + '@vitejs/devtools': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) publint: 0.3.18 typescript: 6.0.3 transitivePeerDependencies: @@ -17298,14 +17689,14 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.4 - unplugin-vue@7.2.0(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3): + unplugin-vue@7.2.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3): dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@vue/reactivity': 3.5.33 obug: 2.1.1 unplugin: 3.0.0 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) transitivePeerDependencies: - '@types/node' @@ -17459,7 +17850,7 @@ snapshots: vite-dev-rpc@1.1.0(vite@8.0.10): dependencies: birpc: 2.9.0 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-hot-client: 2.1.0(vite@8.0.10) vite-hot-client@2.1.0(vite@8.0.10(@types/node@25.0.3)(@vitejs/devtools@packages+core)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): @@ -17468,15 +17859,36 @@ snapshots: vite-hot-client@2.1.0(vite@8.0.10): dependencies: - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + + vite-node@5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.0.0 + obug: 2.1.1 + pathe: 2.0.3 + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml - vite-node@5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + vite-node@5.3.0(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 7.0.0 es-module-lexer: 2.0.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -17540,7 +17952,7 @@ snapshots: proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 tinyglobby: 0.2.16 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vscode-uri: 3.1.0 optionalDependencies: eslint: 10.2.1(jiti@2.6.1) @@ -17575,7 +17987,7 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vite-dev-rpc: 1.1.0(vite@8.0.10) optionalDependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) @@ -17593,7 +18005,7 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) transitivePeerDependencies: @@ -17631,7 +18043,7 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) vite-prerender-plugin@0.5.13(vite@8.0.10): @@ -17642,9 +18054,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - vite@8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -17653,7 +18065,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.0.3 - '@vitejs/devtools': 0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) + '@vitejs/devtools': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) esbuild: 0.28.0 fsevents: 2.3.3 jiti: 2.6.1 @@ -17661,7 +18073,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -17670,7 +18082,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.0.3 - '@vitejs/devtools': 0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) + '@vitejs/devtools': 0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10) esbuild: 0.28.0 fsevents: 2.3.3 jiti: 2.6.1 @@ -17707,10 +18119,10 @@ snapshots: optionalDependencies: vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@packages+core)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - vitepress-plugin-mermaid@2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)): + vitepress-plugin-mermaid@2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)): dependencies: mermaid: 11.14.0 - vitepress: 2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) + vitepress: 2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) optionalDependencies: '@mermaid-js/mermaid-mindmap': 9.3.0 @@ -17721,7 +18133,7 @@ snapshots: optionalDependencies: '@mermaid-js/mermaid-mindmap': 9.3.0 - vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3): + vitepress@2.0.0-alpha.17(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.3.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.128.0)(postcss@8.5.12)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3): dependencies: '@docsearch/css': 4.5.4 '@docsearch/js': 4.5.4 @@ -17740,7 +18152,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 3.22.0 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) optionalDependencies: oxc-minify: 0.128.0 @@ -17841,7 +18253,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.15(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.0.3)(@vitejs/devtools@0.1.18(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(@nuxt/kit@4.4.4(magicast@0.5.2))(@pnpm/logger@1001.0.1)(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)(typescript@6.0.3)(vite@8.0.10))(esbuild@0.28.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17921,12 +18333,6 @@ snapshots: '@vue/language-core': 3.2.7 typescript: 6.0.3 - vue-virtual-scroller@2.0.1(vue@3.5.33(typescript@6.0.3)): - dependencies: - mitt: 2.1.0 - vue: 3.5.33(typescript@6.0.3) - optional: true - vue-virtual-scroller@3.0.0(vue@3.5.33(typescript@6.0.3)): dependencies: mitt: 2.1.0 @@ -17982,7 +18388,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.1 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.4 es-module-lexer: 2.0.0 diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts index 84e057a3..87467e46 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts @@ -71,7 +71,6 @@ export { DevToolsServerCommandInput } export { DevToolsTerminalHost } export { DevToolsTerminalSession } export { DevToolsTerminalSessionBase } -export { DevToolsTerminalSessionStreamChunkEvent } export { DevToolsTerminalStatus } export { DevToolsViewAction } export { DevToolsViewBuiltin } @@ -98,6 +97,9 @@ export { RpcDefinitionsToFunctions } export { RpcFunctionsHost } export { RpcSharedStateGetOptions } export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } export { Thenable } export { ViteDevToolsNodeContext } export { WhenContext } diff --git a/tsconfig.base.json b/tsconfig.base.json index b19c0810..858081ed 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -51,6 +51,9 @@ "devframe/utils/state": [ "./devframe/packages/devframe/src/utils/state.ts" ], + "devframe/utils/streaming-channel": [ + "./devframe/packages/devframe/src/utils/streaming-channel.ts" + ], "devframe/utils/when": [ "./devframe/packages/devframe/src/utils/when.ts" ], diff --git a/turbo.json b/turbo.json index d9c15448..ed9fecc9 100644 --- a/turbo.json +++ b/turbo.json @@ -62,6 +62,13 @@ "outputs": [ "dist/**" ] + }, + "devframe-streaming-chat-example#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": [ + "dist/**" + ] } } } diff --git a/vitest.config.ts b/vitest.config.ts index b0aecf76..a68e1ed8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/*', 'devframe/packages/*', 'devframe/examples/devframe-files-inspector', + 'devframe/examples/devframe-streaming-chat', 'devframe/tests', 'test', ], From 6bad6cecdc85b76a444e40bd8225b74bd8b6b667 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 7 May 2026 15:23:28 +0900 Subject: [PATCH 2/5] fix(devframe): register noop auth handler in standalone server when auth: false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser client unconditionally calls `vite:anonymous:auth` on connect (`client/rpc-ws.ts`), but devframe's standalone server never registered a handler for it — so any non-Vite example (CLI / SPA adapters) hit `[birpc] function "vite:anonymous:auth" not found` the moment a panel opened. The `auth?: boolean` option on `startHttpAndWs` was already wired but did nothing. Now `auth: false` registers a small noop handler that auto-trusts the session, satisfying the client's hardcoded handshake. Vite consumers never opt into `auth: false`, so the real `vite:anonymous:auth` registered by `@vitejs/devtools` is unaffected. Also opt the `devframe-streaming-chat` example into `cli.auth: false` so `pnpm run dev` works end-to-end out of the box. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../devframe-streaming-chat/src/devtool.ts | 3 +++ devframe/packages/devframe/src/node/server.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/devframe/examples/devframe-streaming-chat/src/devtool.ts b/devframe/examples/devframe-streaming-chat/src/devtool.ts index bb54ff17..9d9bc532 100644 --- a/devframe/examples/devframe-streaming-chat/src/devtool.ts +++ b/devframe/examples/devframe-streaming-chat/src/devtool.ts @@ -55,6 +55,9 @@ export default defineDevtool({ command: 'devframe-streaming-chat', port: 9897, distDir, + // Single-user localhost demo — skip the trust handshake that the + // Vite-side surface requires. + auth: false, }, spa: { loader: 'none' }, async setup(ctx) { diff --git a/devframe/packages/devframe/src/node/server.ts b/devframe/packages/devframe/src/node/server.ts index 77c11e5c..68f0c5cb 100644 --- a/devframe/packages/devframe/src/node/server.ts +++ b/devframe/packages/devframe/src/node/server.ts @@ -102,6 +102,25 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { + const session = rpcHost.getCurrentRpcSession() + if (session) + session.meta.isTrusted = true + return { isTrusted: true } + }, + }) + } + await new Promise((resolveListen) => { httpServer.listen(port, bindHost, () => resolveListen()) }) From 0d5ca6ed5806e36abdb190d11ed3f363cb9a8bb2 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 7 May 2026 15:36:05 +0900 Subject: [PATCH 3/5] feat(example): real chat UI with shared-state history in streaming-chat Upgrades the streaming-chat example from a single-prompt scratch pad into a real multi-turn chat: - Conversation log lives in a devframe `sharedState` keyed `devframe-streaming-chat:history`. Each `send` action atomically appends a user message and an assistant placeholder; tokens stream live, and the joined content is committed back to shared state when the producer closes. Refresh the page and the log comes back; open a second panel and both stay in sync. - New UI: chat-bubble layout with user / assistant styling, dark-mode CSS, demo-prompt chips, send / cancel / clear controls, auto-scroll on new messages, blinking cursor while streaming. - Cancellation: `reader.cancel()` flows through to the producer, which commits the partial content with `cancelled: true`. The UI keeps the partial response visible. - Replay: `replayWindow: 1024` means a panel reopened mid-stream sees the buffered tokens before resuming live. Also adds RPC actions `chat:send` and `chat:clear`, and updates tests to verify history grows across turns, cancellation persists partial content, and clear empties the log. README covers running locally and swapping in a real LLM via OpenAI's streaming API. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../devframe-streaming-chat/README.md | 62 +++- .../src/client/app.tsx | 267 +++++++++++++----- .../src/client/index.html | 174 ++++++++++-- .../devframe-streaming-chat/src/devtool.ts | 149 ++++++++-- .../devframe-streaming-chat/tests/_utils.ts | 9 +- .../tests/streaming-chat.test.ts | 188 +++++++----- 6 files changed, 654 insertions(+), 195 deletions(-) diff --git a/devframe/examples/devframe-streaming-chat/README.md b/devframe/examples/devframe-streaming-chat/README.md index 2bf51c4a..b52b6d94 100644 --- a/devframe/examples/devframe-streaming-chat/README.md +++ b/devframe/examples/devframe-streaming-chat/README.md @@ -1,19 +1,27 @@ # devframe-streaming-chat -End-to-end demo of devframe's streaming-channel API. Mirrors the AI-deltas -use case from [vitejs/devtools#306](https://github.com/vitejs/devtools/issues/306): -the server emits synthesized "tokens" one at a time, and the browser renders -them incrementally with `for await`. +End-to-end demo of devframe's streaming-channel API combined with shared +state for persistent chat history. Mirrors the AI-deltas use case from +[vitejs/devtools#306](https://github.com/vitejs/devtools/issues/306): +the server emits synthesized "tokens" one at a time over a streaming +channel, while the conversation log lives in a devframe `sharedState` so +it survives reloads, syncs across panels, and replays cleanly when a +client (re)joins mid-stream. ## What it shows - `ctx.rpc.streaming.create(name, opts)` registers a streaming channel. -- An `action` RPC starts a stream and returns its `streamId`. -- The client calls `rpc.streaming.subscribe(name, id)` and consumes via - `for await (const token of reader)`. -- `reader.cancel()` aborts the server-side `stream.signal` so the producer - exits cleanly mid-stream. -- Reopening the panel mid-stream replays buffered tokens (replayWindow: 256). +- `ctx.rpc.sharedState.get('devframe-streaming-chat:history', …)` keeps + the message log on the server. Each `send` action appends a user + + assistant pair atomically. +- The producer streams tokens via the channel for low-latency rendering, + then commits the joined content back to the shared state when it's + done — so refreshes and new clients see the finished message + immediately. +- `reader.cancel()` aborts mid-stream; the assistant message is marked + `cancelled: true` with whatever content was accumulated. +- `replayWindow: 1024` means a panel reopened mid-stream replays the + buffered tokens before resuming live. ## Run it @@ -22,7 +30,9 @@ pnpm -C devframe/examples/devframe-streaming-chat run build pnpm -C devframe/examples/devframe-streaming-chat run dev ``` -Then open http://localhost:9897/ and pick a demo prompt, or type your own. +Then open http://localhost:9897/ — type a prompt, watch tokens stream +in, refresh the page mid-conversation, cancel a long answer, click +**Clear** to wipe the log. ## Run the tests @@ -31,4 +41,32 @@ pnpm -C devframe/examples/devframe-streaming-chat run test ``` Tests boot the server in-process and exercise the full WS round-trip: -happy path, cancellation, fan-out across two clients, and replay-after-resubscribe. +happy path with shared-state commit, multi-turn history, cancellation +with partial content, clear, and replay-after-finish. + +## Wire it to a real LLM + +Replace `fakeTokens(prompt)` in `src/devtool.ts` with anything that +yields strings — the rest of the example doesn't care. For OpenAI: + +```ts +const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: prompt }], +}) +for await (const chunk of response) { + if (stream.signal.aborted) + break + const token = chunk.choices[0]?.delta?.content + if (token) { + stream.write(token) + acc += token + } +} +stream.close() +``` + +`stream.signal` propagates cancellation from the browser → server → +`openai.chat.completions.create`'s own AbortController, so cancelling +also stops the upstream request. diff --git a/devframe/examples/devframe-streaming-chat/src/client/app.tsx b/devframe/examples/devframe-streaming-chat/src/client/app.tsx index 48dd93ad..f2c3c2ef 100644 --- a/devframe/examples/devframe-streaming-chat/src/client/app.tsx +++ b/devframe/examples/devframe-streaming-chat/src/client/app.tsx @@ -1,19 +1,24 @@ import type { DevToolsRpcClient } from 'devframe/client' import type { StreamReader } from 'devframe/utils/streaming-channel' +import type { ChatHistory, ChatMessage } from '../devtool' import { connectDevtool } from 'devframe/client' -import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' const CHANNEL_NAME = 'devframe-streaming-chat:tokens' +const HISTORY_KEY = 'devframe-streaming-chat:history' export function App() { const [rpc, setRpc] = useState(null) const [demoPrompts, setDemoPrompts] = useState([]) - const [prompt, setPrompt] = useState('Tell me about devframe.') - const [output, setOutput] = useState('') - const [streaming, setStreaming] = useState(false) + const [messages, setMessages] = useState([]) + const [liveTokens, setLiveTokens] = useState>({}) + const [prompt, setPrompt] = useState('') const [error, setError] = useState(null) - const readerRef = useRef | null>(null) + const readersRef = useRef>>(new Map()) + const messagesRef = useRef(null) + + // Connect once and surface demo prompts. useEffect(() => { let cancelled = false connectDevtool().then(async (r) => { @@ -21,11 +26,11 @@ export function App() { return setRpc(r) try { - const { prompts } = await r.call( + const result = await r.call( 'devframe-streaming-chat:demo-prompts' as any, ) as { prompts: string[] } if (!cancelled) - setDemoPrompts(prompts) + setDemoPrompts(result.prompts) } catch { // demo prompts are optional @@ -33,106 +38,209 @@ export function App() { }) return () => { cancelled = true - readerRef.current?.cancel() + for (const reader of readersRef.current.values()) + reader.cancel() + readersRef.current.clear() } }, []) - const start = useCallback(async (text: string) => { - if (!rpc || streaming || !text.trim()) + // Bind to the server-side chat history shared state. + useEffect(() => { + if (!rpc) return - setError(null) - setOutput('') - setStreaming(true) - try { - const { streamId } = await rpc.call( - 'devframe-streaming-chat:start' as any, - { prompt: text }, - ) as { streamId: string } + let off: (() => void) | undefined + let active = true + rpc.sharedState + .get(HISTORY_KEY, { initialValue: { messages: [] } }) + .then((state) => { + if (!active) + return + setMessages(state.value().messages as ChatMessage[]) + off = state.on('updated', (full: ChatHistory) => { + setMessages([...full.messages]) + }) + }) + return () => { + active = false + off?.() + } + }, [rpc]) - const reader = rpc.streaming.subscribe(CHANNEL_NAME, streamId) - readerRef.current = reader - try { - for await (const token of reader) - setOutput(prev => prev + token) - } - finally { - readerRef.current = null + // For each assistant message that's currently streaming, subscribe to the + // tokens channel and accumulate into `liveTokens`. When the server commits + // the final content (`streamId` cleared), we drop the live overlay. + useEffect(() => { + if (!rpc) + return + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.streamId) + continue + if (readersRef.current.has(msg.id)) + continue + + const reader = rpc.streaming.subscribe(CHANNEL_NAME, msg.streamId) + readersRef.current.set(msg.id, reader) + setLiveTokens(prev => ({ ...prev, [msg.id]: '' })) + + ;(async () => { + try { + for await (const token of reader) { + setLiveTokens(prev => ({ + ...prev, + [msg.id]: (prev[msg.id] ?? '') + token, + })) + } + } + catch { + // Stream ended with error — leave whatever we accumulated. + } + })() + } + + // Drop overlays for messages whose stream is now committed. + setLiveTokens((prev) => { + const next = { ...prev } + let changed = false + for (const id of Object.keys(next)) { + const m = messages.find(x => x.id === id) + if (!m || !m.streamId) { + delete next[id] + readersRef.current.delete(id) + changed = true + } } + return changed ? next : prev + }) + }, [rpc, messages]) + + // Auto-scroll on new messages / live tokens. + useEffect(() => { + const el = messagesRef.current + if (!el) + return + el.scrollTop = el.scrollHeight + }, [messages, liveTokens]) + + const activeAssistantId = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.role === 'assistant' && m.streamId) + return m.id + } + return undefined + }, [messages]) + + const isStreaming = !!activeAssistantId + + const send = useCallback(async (text: string) => { + if (!rpc || isStreaming || !text.trim()) + return + setError(null) + setPrompt('') + try { + await rpc.call('devframe-streaming-chat:send' as any, { + prompt: text.trim(), + }) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } - finally { - setStreaming(false) - } - }, [rpc, streaming]) + }, [rpc, isStreaming]) const cancel = useCallback(() => { - readerRef.current?.cancel() - }, []) + if (!activeAssistantId) + return + const reader = readersRef.current.get(activeAssistantId) + reader?.cancel() + }, [activeAssistantId]) + + const clear = useCallback(async () => { + if (!rpc || isStreaming) + return + try { + await rpc.call('devframe-streaming-chat:clear' as any) + } + catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [rpc, isStreaming]) if (!rpc) - return

Connecting to devtool…

+ return

Connecting to devtool…

return (
-

Streaming Chat

- - A devframe streaming-API demo. Server emits one token every ~40 ms; - client renders incrementally via - {' '} - for await - . - +
+

Streaming Chat

+ history persists in shared state · tokens stream over a channel +
+
+ +
+
+ {messages.length === 0 + ? ( +
+

No messages yet.

+

+ Type a prompt and hit + {' '} + Enter + {' '} + — or pick a demo prompt below. +

+
+ ) + : messages.map(msg => )} +
+ + {!isStreaming && demoPrompts.length > 0 && ( +
+ {demoPrompts.map(p => ( + + ))} +
+ )} +
{ e.preventDefault() - start(prompt) + send(prompt) }} > setPrompt((e.target as HTMLInputElement).value)} - placeholder="Ask anything…" - disabled={streaming} + placeholder={isStreaming ? 'Streaming reply… cancel to send another' : 'Ask anything…'} + disabled={isStreaming} /> - {!streaming - ? - : } + {isStreaming + ? + : }
- {demoPrompts.length > 0 && !streaming && ( -
- try: - {demoPrompts.map(p => ( - - ))} -
- )} - -
{output}
-
backend: {' '} {rpc.connectionMeta.backend} + {' · '} + {messages.length} + {' '} + message + {messages.length === 1 ? '' : 's'} {error && ( - {' '} - · error: + {' · error: '} {error} )} @@ -140,3 +248,24 @@ export function App() {
) } + +function Message({ msg, live }: { msg: ChatMessage, live: string | undefined }) { + // Prefer the live token overlay while streaming; fall back to the + // committed content from shared state once the producer closes. + const displayed = msg.streamId !== undefined && live !== undefined + ? live + : msg.content + const cls = [ + 'msg', + `msg-${msg.role}`, + msg.streamId ? 'streaming' : '', + msg.cancelled ? 'cancelled' : '', + ].filter(Boolean).join(' ') + + return ( +
+ {displayed || (msg.streamId ? '' : '(empty)')} + {msg.cancelled &&
cancelled
} +
+ ) +} diff --git a/devframe/examples/devframe-streaming-chat/src/client/index.html b/devframe/examples/devframe-streaming-chat/src/client/index.html index 8630157c..d05d9895 100644 --- a/devframe/examples/devframe-streaming-chat/src/client/index.html +++ b/devframe/examples/devframe-streaming-chat/src/client/index.html @@ -6,28 +6,168 @@ Streaming Chat diff --git a/devframe/examples/devframe-streaming-chat/src/devtool.ts b/devframe/examples/devframe-streaming-chat/src/devtool.ts index 9d9bc532..eeb50cca 100644 --- a/devframe/examples/devframe-streaming-chat/src/devtool.ts +++ b/devframe/examples/devframe-streaming-chat/src/devtool.ts @@ -1,12 +1,15 @@ import { fileURLToPath } from 'node:url' import { defineRpcFunction } from 'devframe' import { defineDevtool } from 'devframe/types' +import { nanoid } from 'devframe/utils/nanoid' import * as v from 'valibot' const BASE_PATH = '/.devframe-streaming-chat/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) const CHANNEL_NAME = 'devframe-streaming-chat:tokens' +const HISTORY_KEY = 'devframe-streaming-chat:history' +const MAX_HISTORY = 200 const DEMO_PROMPTS = [ 'Tell me about devframe.', @@ -14,16 +17,39 @@ const DEMO_PROMPTS = [ 'Write a haiku about RPC.', ] as const +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + /** Set on assistant messages while their stream is in flight. */ + streamId?: string + /** True if the assistant stream was cancelled before completing. */ + cancelled?: boolean + timestamp: number +} + +export interface ChatHistory { + messages: ChatMessage[] +} + +declare module 'devframe/types' { + interface DevToolsRpcSharedStates { + [HISTORY_KEY]: ChatHistory + } +} + /** - * Synthetic "AI" — splits a fake response into tokens and emits one - * every `intervalMs` milliseconds. Replaceable with any real provider - * (OpenAI SDK's `stream: true` returns an async iterable that pipes - * straight into `channel.pipeFrom(Readable.toWeb(stream))`). + * Synthetic "AI" — splits a canned response into tokens and emits them + * one at a time. Swap in `OpenAI`'s `chat.completions.create({ stream: true })` + * (or any async iterable of strings) to make it real. */ function* fakeTokens(prompt: string): Generator { const lower = prompt.toLowerCase() let response: string - if (lower.includes('haiku')) { + if (/^(?:hi|hello|hey)\b/.test(lower)) { + response = `Hello! Ask me about devframe, streaming, or anything else — I'll fake-stream a response one token at a time.` + } + else if (lower.includes('haiku')) { response = 'Tiny chunks arrive — / type-safe over WebSocket / streams compose with ease.' } else if (lower.includes('streaming')) { @@ -33,15 +59,22 @@ function* fakeTokens(prompt: string): Generator { + '`for await (const chunk of reader)`. Cancellation, replay, and ' + 'backpressure are wired by the host — your handler stays small.' } + else if (lower.includes('history') || lower.includes('persist')) { + response + = `History lives in a devframe shared state ("${HISTORY_KEY}"). ` + + 'Each `send` appends a user + assistant pair; tokens stream live, ' + + 'and the final content is committed back to the shared state when ' + + 'the producer closes. Refresh the page and the log comes back.' + } else { response = `You asked: "${prompt}". ` + 'devframe is a framework-neutral foundation for building developer ' - + 'tooling — six adapters, type-safe RPC, shared state, and now a ' - + 'first-class streaming channel for delta-style server→client data. ' - + 'Pipe `ReadableStream`s directly into a sink, or write chunks by hand.' + + 'tooling — six adapters, type-safe RPC, shared state, and a ' + + 'first-class streaming channel for delta-style server↔client data. ' + + 'Pipe `ReadableStream`s into a sink, or write chunks by hand.' } - // Split on whitespace but keep the spaces so `output.join('')` round-trips. + // Split on whitespace but keep the spaces so `tokens.join('')` round-trips. const tokens = response.split(/(\s+)/).filter(Boolean) for (const token of tokens) yield token } @@ -61,11 +94,22 @@ export default defineDevtool({ }, spa: { loader: 'none' }, async setup(ctx) { - // Create the streaming channel up-front so demo prompts can reuse it. const channel = ctx.rpc.streaming.create(CHANNEL_NAME, { - replayWindow: 256, + replayWindow: 1024, + }) + + const history = await ctx.rpc.sharedState.get(HISTORY_KEY, { + initialValue: { messages: [] }, }) + function pruneIfTooLarge(): void { + if (history.value().messages.length > MAX_HISTORY) { + history.mutate((draft) => { + draft.messages.splice(0, draft.messages.length - MAX_HISTORY) + }) + } + } + ctx.rpc.register(defineRpcFunction({ name: 'devframe-streaming-chat:demo-prompts', type: 'static', @@ -74,35 +118,98 @@ export default defineDevtool({ })) ctx.rpc.register(defineRpcFunction({ - name: 'devframe-streaming-chat:start', + name: 'devframe-streaming-chat:send', type: 'action', jsonSerializable: true, args: [v.object({ prompt: v.string(), - intervalMs: v.optional(v.number(), 40), + intervalMs: v.optional(v.number(), 35), })], - returns: v.object({ streamId: v.string() }), - handler: async ({ prompt, intervalMs = 40 }) => { + returns: v.object({ + userId: v.string(), + assistantId: v.string(), + streamId: v.string(), + }), + handler: async ({ prompt, intervalMs = 35 }) => { const stream = channel.start() + const userId = nanoid() + const assistantId = nanoid() + const now = Date.now() + + // Append both messages atomically — clients see the user prompt + // and the empty assistant placeholder appear together. + history.mutate((draft) => { + draft.messages.push({ + id: userId, + role: 'user', + content: prompt, + timestamp: now, + }) + draft.messages.push({ + id: assistantId, + role: 'assistant', + content: '', + streamId: stream.id, + timestamp: now, + }) + }) + pruneIfTooLarge() - // Producer task — fire-and-forget. Each chunk goes out the channel; - // we cooperate with cancellation by polling `stream.signal.aborted`. + // Producer — token-by-token via streaming, full content committed + // to shared state when done so refreshes / new clients see the + // finished message without re-streaming. ;(async () => { + let acc = '' + let cancelled = false try { for (const token of fakeTokens(prompt)) { - if (stream.signal.aborted) - return + if (stream.signal.aborted) { + cancelled = true + break + } stream.write(token) + acc += token await new Promise(r => setTimeout(r, intervalMs)) } - stream.close() + if (!cancelled) + stream.close() } catch (err) { stream.error(err) + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + msg.cancelled = true + } + }) + return } + + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + if (cancelled) + msg.cancelled = true + } + }) })() - return { streamId: stream.id } + return { userId, assistantId, streamId: stream.id } + }, + })) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:clear', + type: 'action', + jsonSerializable: true, + handler: () => { + history.mutate((draft) => { + draft.messages.length = 0 + }) }, })) diff --git a/devframe/examples/devframe-streaming-chat/tests/_utils.ts b/devframe/examples/devframe-streaming-chat/tests/_utils.ts index dd1ab325..26f236ab 100644 --- a/devframe/examples/devframe-streaming-chat/tests/_utils.ts +++ b/devframe/examples/devframe-streaming-chat/tests/_utils.ts @@ -1,4 +1,4 @@ -import type { StartedServer } from 'devframe/node' +import type { DevToolsNodeContext, StartedServer } from 'devframe/node' import { existsSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -27,7 +27,10 @@ export const CLIENT_DIST = resolve(HERE, '../dist/client') * Bound to 127.0.0.1 to avoid the IPv4/IPv6 race documented in * `packages/devframe/src/rpc/transports/ws.test.ts`. */ -export async function startStreamingChatServer(): Promise { +export async function startStreamingChatServer(): Promise { // Build the client only if a test exercises the served HTML — RPC-only // tests don't need the dist (we don't call assertClientBuilt unless the // test fetches index.html). @@ -72,5 +75,5 @@ export async function startStreamingChatServer(): Promise @@ -19,6 +21,8 @@ interface FakeClient { * Build a minimal RPC client + streaming host. We don't go through * `connectDevtool` because that needs a browser-like environment for * connection-meta lookup; the WS channel is what matters for streaming. + * Shared-state syncing happens server-side, so tests inspect it through + * the harness `ctx` rather than over the wire. */ function bootClient(port: number): FakeClient { const listeners = new Set<(trusted: boolean) => void>() @@ -29,7 +33,6 @@ function bootClient(port: number): FakeClient { return () => listeners.delete(fn) }, } - const clientFns: any = {} const clientRpcStub = { register(def: { name: string, handler: (...args: any[]) => any }) { @@ -55,8 +58,33 @@ function bootClient(port: number): FakeClient { return { rpc, streaming } } +interface SendResult { + userId: string + assistantId: string + streamId: string +} + +async function send(client: FakeClient, prompt: string, intervalMs = 1): Promise { + return await (client.rpc as any).$call('devframe-streaming-chat:send', { + prompt, + intervalMs, + }) as SendResult +} + +async function readAll(reader: AsyncIterable): Promise { + const out: string[] = [] + for await (const chunk of reader) + out.push(chunk) + return out +} + +async function getHistory(ctx: DevToolsNodeContext): Promise { + const state = await ctx.rpc.sharedState.get(HISTORY_KEY) + return state.value() as ChatHistory +} + describe('devframe-streaming-chat (example)', () => { - let server: StartedServer & { basePath: string } + let server: StartedServer & { basePath: string, ctx: DevToolsNodeContext } beforeEach(async () => { server = await startStreamingChatServer() @@ -66,102 +94,116 @@ describe('devframe-streaming-chat (example)', () => { await server?.close() }) - it('streams tokens for a prompt and joins back to the full response', async () => { + it('appends user + assistant pair and commits final content to history', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) - const { streamId } = await (client.rpc as any).$call( - 'devframe-streaming-chat:start', - { prompt: 'Tell me about devframe.', intervalMs: 1 }, - ) as { streamId: string } - + const { userId, assistantId, streamId } = await send(client, 'Tell me about devframe.') const reader = client.streaming.subscribe(CHANNEL, streamId) - const collected: string[] = [] - for await (const token of reader) - collected.push(token) - - expect(collected.length).toBeGreaterThan(5) - expect(collected.join('')).toContain('devframe') - expect(collected.join('')).toContain('You asked') + const tokens = await readAll(reader) + const fullText = tokens.join('') + + expect(tokens.length).toBeGreaterThan(5) + expect(fullText).toContain('You asked') + + // Wait for the post-stream sharedState mutation to land. + await vi.waitFor(async () => { + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId) + expect(assistant?.streamId).toBeUndefined() + }) + + const history = await getHistory(server.ctx) + expect(history.messages).toHaveLength(2) + expect(history.messages[0]).toMatchObject({ + id: userId, + role: 'user', + content: 'Tell me about devframe.', + }) + expect(history.messages[1]).toMatchObject({ + id: assistantId, + role: 'assistant', + content: fullText, + }) }) - it('cancellation aborts the producer mid-stream', async () => { + it('persists history across multiple turns', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) - const { streamId } = await (client.rpc as any).$call( - 'devframe-streaming-chat:start', - { prompt: 'How does streaming work?', intervalMs: 30 }, - ) as { streamId: string } + for (const prompt of ['hi', 'How does streaming work?', 'Write a haiku about RPC.']) { + const { streamId } = await send(client, prompt) + await readAll(client.streaming.subscribe(CHANNEL, streamId)) + } + await new Promise(r => setTimeout(r, 50)) + const history = await getHistory(server.ctx) + expect(history.messages).toHaveLength(6) + expect(history.messages.map(m => m.role)).toEqual([ + 'user', + 'assistant', + 'user', + 'assistant', + 'user', + 'assistant', + ]) + expect(history.messages.every(m => !m.streamId)).toBe(true) + }) + + it('cancellation marks the assistant message and saves partial content', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { assistantId, streamId } = await send(client, 'Tell me about devframe.', 30) const reader = client.streaming.subscribe(CHANNEL, streamId) - const collected: string[] = [] - const consumer = (async () => { - for await (const token of reader) { - collected.push(token) - if (collected.length >= 3) - reader.cancel() - } - })() - - await consumer - const collectedAtCancel = collected.length - - // Wait long enough that more tokens would have arrived had we not - // cancelled, then assert no more came in. - await new Promise(r => setTimeout(r, 200)) - expect(collected.length).toBe(collectedAtCancel) - expect(reader.cancelled).toBe(true) + const collected: string[] = [] + for await (const token of reader) { + collected.push(token) + if (collected.length >= 3) + reader.cancel() + } + + await vi.waitFor(async () => { + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId) + expect(assistant?.cancelled).toBe(true) + }) + + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId)! + expect(assistant.streamId).toBeUndefined() + expect(assistant.content.length).toBeGreaterThan(0) + // Partial content — the canned "devframe" response is well over 200 chars. + expect(assistant.content.length).toBeLessThan(200) }) - it('fans out the same chunks to two subscribers', async () => { - const a = bootClient(server.port) - const b = bootClient(server.port) + it('clears history on demand', async () => { + const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) - const { streamId } = await (a.rpc as any).$call( - 'devframe-streaming-chat:start', - { prompt: 'Write a haiku about RPC.', intervalMs: 1 }, - ) as { streamId: string } + const { streamId } = await send(client, 'Tell me about devframe.') + await readAll(client.streaming.subscribe(CHANNEL, streamId)) + await new Promise(r => setTimeout(r, 30)) - const readerA = a.streaming.subscribe(CHANNEL, streamId) - const readerB = b.streaming.subscribe(CHANNEL, streamId) + expect((await getHistory(server.ctx)).messages).toHaveLength(2) - const collectedA: string[] = [] - const collectedB: string[] = [] - await Promise.all([ - (async () => { for await (const t of readerA) collectedA.push(t) })(), - (async () => { for await (const t of readerB) collectedB.push(t) })(), - ]) + await (client.rpc as any).$call('devframe-streaming-chat:clear') + await new Promise(r => setTimeout(r, 30)) - expect(collectedA.join('')).toBe(collectedB.join('')) - // Haiku-prompt response uses these tokens; assert content matches the - // canned response so we know the producer routed by prompt correctly. - expect(collectedA.join('')).toContain('Tiny chunks arrive') + expect((await getHistory(server.ctx)).messages).toHaveLength(0) }) - it('replays buffered tokens for a late subscriber within the replayWindow', async () => { + it('replays buffered tokens for a late subscriber', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) - // Fire the action; the producer starts emitting at intervalMs=1. - const { streamId } = await (client.rpc as any).$call( - 'devframe-streaming-chat:start', - { prompt: 'Tell me about devframe.', intervalMs: 5 }, - ) as { streamId: string } - - // Wait long enough that the producer has fully emitted into the - // server's ring buffer (size 256, more than enough for our response). - await new Promise(r => setTimeout(r, 800)) + const { streamId } = await send(client, 'Tell me about devframe.', 5) - // Subscribe AFTER the producer has finished — the replay window - // means we still receive every token + the end frame. - const reader = client.streaming.subscribe(CHANNEL, streamId) - const collected: string[] = [] - for await (const token of reader) - collected.push(token) + // Wait for the producer to finish before subscribing. + await new Promise(r => setTimeout(r, 600)) + const collected = await readAll(client.streaming.subscribe(CHANNEL, streamId)) expect(collected.length).toBeGreaterThan(5) expect(collected.join('')).toContain('You asked') }) From 31e3af4844bd166fd113aeb9cb11a3ae5aa34f87 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 7 May 2026 15:49:07 +0900 Subject: [PATCH 4/5] fix(devframe): register shared-state sync RPCs in standalone server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four `devtoolskit:internal:rpc:server-state:*` RPC handlers (`subscribe` / `get` / `set` / `patch`) were defined only in `packages/core` and registered via core's builtin set. devframe's client (`client/rpc-shared-state.ts`) hardcodes calls to those names, so any non-Vite host (CLI / SPA / build adapters) hit `[birpc] function "devtoolskit:internal:rpc:server-state:get" not found` the moment a panel tried to read shared state. Move the four handlers into `createRpcSharedStateServerHost` so any `RpcFunctionsHost` ships with the full sync protocol, and add the matching wire methods to `DevToolsRpcServerFunctions` so callers stay typed. Delete the duplicate definitions from `packages/core`. This is the same class of fix as the previous `vite:anonymous:auth` patch — both wire methods are part of devframe's protocol but used to live in the wrong package. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../devframe/src/node/rpc-shared-state.ts | 60 ++++++++++++++++++- .../devframe/src/types/rpc-augments.ts | 28 +++++++++ packages/core/src/node/rpc/index.ts | 8 --- .../core/src/node/rpc/internal/state/get.ts | 23 ------- .../core/src/node/rpc/internal/state/patch.ts | 18 ------ .../core/src/node/rpc/internal/state/set.ts | 15 ----- .../src/node/rpc/internal/state/subscribe.ts | 21 ------- 7 files changed, 86 insertions(+), 87 deletions(-) delete mode 100644 packages/core/src/node/rpc/internal/state/get.ts delete mode 100644 packages/core/src/node/rpc/internal/state/patch.ts delete mode 100644 packages/core/src/node/rpc/internal/state/set.ts delete mode 100644 packages/core/src/node/rpc/internal/state/subscribe.ts diff --git a/devframe/packages/devframe/src/node/rpc-shared-state.ts b/devframe/packages/devframe/src/node/rpc-shared-state.ts index 890609b5..20032afc 100644 --- a/devframe/packages/devframe/src/node/rpc-shared-state.ts +++ b/devframe/packages/devframe/src/node/rpc-shared-state.ts @@ -1,10 +1,11 @@ -import type { RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' -import type { SharedState } from 'devframe/utils/shared-state' +import type { DevToolsRpcSharedStates, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' +import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import { createSharedState } from 'devframe/utils/shared-state' import { createDebug } from 'obug' import { logger } from './diagnostics' const debug = createDebug('vite:devtools:rpc:state:changed') +const debugSubscribe = createDebug('vite:devtools:rpc:state:subscribe') export function createRpcSharedStateServerHost( rpc: RpcFunctionsHost, @@ -73,5 +74,60 @@ export function createRpcSharedStateServerHost( }, } + // Wire methods that the client-side `client/rpc-shared-state.ts` + // calls to subscribe / get / push patches / push full snapshots. + // Registering them here keeps shared-state self-contained: any + // server built on `RpcFunctionsHost` (devframe standalone or kit / + // core) gets the full sync protocol out of the box. + rpc.register({ + name: 'devtoolskit:internal:rpc:server-state:subscribe', + type: 'event', + handler(key: string) { + const session = rpc.getCurrentRpcSession() + if (!session) + return + debugSubscribe('subscribe', { key, session: session.meta.id }) + session.meta.subscribedStates.add(key) + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:rpc:server-state:get', + type: 'query', + handler: async (key: string) => { + if (!sharedState.has(key)) + return undefined + const state = await host.get(key as keyof DevToolsRpcSharedStates) + return state.value() + }, + // Pre-compute snapshots for the build-mode static dump so the SPA + // can read them without a live server. + dump: () => ({ + inputs: host.keys().map(key => [key] as [string]), + }), + }) + + rpc.register({ + name: 'devtoolskit:internal:rpc:server-state:set', + type: 'query', + handler: async (key: string, value: any, syncId: string) => { + const state = await host.get(key as keyof DevToolsRpcSharedStates, { + initialValue: value, + }) + state.mutate(() => value, syncId) + }, + }) + + rpc.register({ + name: 'devtoolskit:internal:rpc:server-state:patch', + type: 'query', + handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { + if (!sharedState.has(key)) + return + const state = await host.get(key as keyof DevToolsRpcSharedStates) + state.patch(patches, syncId) + }, + }) + return host } diff --git a/devframe/packages/devframe/src/types/rpc-augments.ts b/devframe/packages/devframe/src/types/rpc-augments.ts index 973cbb1c..4b2aaed7 100644 --- a/devframe/packages/devframe/src/types/rpc-augments.ts +++ b/devframe/packages/devframe/src/types/rpc-augments.ts @@ -29,6 +29,34 @@ export interface DevToolsRpcClientFunctions { * To be extended */ export interface DevToolsRpcServerFunctions { + /** + * Subscribe a client to a shared-state key. Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:rpc:server-state:subscribe': (key: string) => Promise + /** + * Read the current value for a shared-state key. Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:rpc:server-state:get': (key: string) => Promise + /** + * Replace a shared-state value (from the client). Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:rpc:server-state:set': (key: string, value: any, syncId: string) => Promise + /** + * Apply a patch to a shared-state value (from the client). Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devtoolskit:internal:rpc:server-state:patch': (key: string, patches: any[], syncId: string) => Promise /** * Client→server streaming subscription with optional replay cursor. * Wired by `RpcStreamingHost`; do not register manually. diff --git a/packages/core/src/node/rpc/index.ts b/packages/core/src/node/rpc/index.ts index f41960fb..38974164 100644 --- a/packages/core/src/node/rpc/index.ts +++ b/packages/core/src/node/rpc/index.ts @@ -10,10 +10,6 @@ import { messagesList } from './internal/messages-list' import { messagesRemove } from './internal/messages-remove' import { messagesUpdate } from './internal/messages-update' import { rpcServerList } from './internal/rpc-server-list' -import { sharedStateGet } from './internal/state/get' -import { sharedStatePatch } from './internal/state/patch' -import { sharedStateSet } from './internal/state/set' -import { sharedStateSubscribe } from './internal/state/subscribe' import { terminalsList } from './internal/terminals-list' import { terminalsRead } from './internal/terminals-read' import { openInEditor } from './public/open-in-editor' @@ -40,10 +36,6 @@ export const builtinInternalRpcDeclarations = [ messagesRemove, messagesUpdate, rpcServerList, - sharedStateGet, - sharedStatePatch, - sharedStateSet, - sharedStateSubscribe, terminalsList, terminalsRead, ] as const diff --git a/packages/core/src/node/rpc/internal/state/get.ts b/packages/core/src/node/rpc/internal/state/get.ts deleted file mode 100644 index 777f761e..00000000 --- a/packages/core/src/node/rpc/internal/state/get.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { DevToolsNodeContext, DevToolsRpcSharedStates } from '@vitejs/devtools-kit' -import { defineRpcFunction } from '@vitejs/devtools-kit' - -export const sharedStateGet = defineRpcFunction({ - name: 'devtoolskit:internal:rpc:server-state:get', - type: 'query', - dump: (context: DevToolsNodeContext) => { - const host = context.rpc.sharedState - return { - inputs: host.keys().map(key => [key] as const), - } - }, - setup: (context: DevToolsNodeContext) => { - return { - handler: async (key: string): Promise => { - if (!context.rpc.sharedState.keys().includes(key)) - return undefined - const state = await context.rpc.sharedState.get(key as keyof DevToolsRpcSharedStates) - return state.value() - }, - } - }, -}) diff --git a/packages/core/src/node/rpc/internal/state/patch.ts b/packages/core/src/node/rpc/internal/state/patch.ts deleted file mode 100644 index 8e277458..00000000 --- a/packages/core/src/node/rpc/internal/state/patch.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { DevToolsNodeContext, DevToolsRpcSharedStates } from '@vitejs/devtools-kit' -import type { SharedStatePatch } from 'devframe/utils/shared-state' -import { defineRpcFunction } from '@vitejs/devtools-kit' - -export const sharedStatePatch = defineRpcFunction({ - name: 'devtoolskit:internal:rpc:server-state:patch', - type: 'query', - setup: (context: DevToolsNodeContext) => { - return { - handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { - if (!context.rpc.sharedState.keys().includes(key)) - return - const state = await context.rpc.sharedState.get(key as keyof DevToolsRpcSharedStates) - state.patch(patches, syncId) - }, - } - }, -}) diff --git a/packages/core/src/node/rpc/internal/state/set.ts b/packages/core/src/node/rpc/internal/state/set.ts deleted file mode 100644 index 4824e547..00000000 --- a/packages/core/src/node/rpc/internal/state/set.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { DevToolsNodeContext, DevToolsRpcSharedStates } from '@vitejs/devtools-kit' -import { defineRpcFunction } from '@vitejs/devtools-kit' - -export const sharedStateSet = defineRpcFunction({ - name: 'devtoolskit:internal:rpc:server-state:set', - type: 'query', - setup: (context: DevToolsNodeContext) => { - return { - handler: async (key: string, value: any, syncId: string) => { - const state = await context.rpc.sharedState.get(key as keyof DevToolsRpcSharedStates, { initialValue: value }) - state.mutate(() => value, syncId) - }, - } - }, -}) diff --git a/packages/core/src/node/rpc/internal/state/subscribe.ts b/packages/core/src/node/rpc/internal/state/subscribe.ts deleted file mode 100644 index 80b2b0c4..00000000 --- a/packages/core/src/node/rpc/internal/state/subscribe.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { DevToolsNodeContext } from '@vitejs/devtools-kit' -import { defineRpcFunction } from '@vitejs/devtools-kit' -import { createDebug } from 'obug' - -const debug = createDebug('vite:devtools:rpc:state:subscribe') - -export const sharedStateSubscribe = defineRpcFunction({ - name: 'devtoolskit:internal:rpc:server-state:subscribe', - type: 'event', - setup: (context: DevToolsNodeContext) => { - return { - handler: async (key: string): Promise => { - const session = context.rpc.getCurrentRpcSession() - if (!session) - return - debug('subscribe', { key, session: session.meta.id }) - session.meta.subscribedStates.add(key) - }, - } - }, -}) From 034833437b328f9ea59d47f99d61a0576982c63f Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 7 May 2026 15:58:31 +0900 Subject: [PATCH 5/5] docs: streaming guides for kit + agent skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `docs/kit/streaming.md` — kit-flavored streaming guide covering outbound (`channel.start`, `pipeFrom`, `subscribe`), inbound uploads (`openInbound`, `rpc.streaming.upload`), lifecycle, replay, backpressure, and the chat-history pattern that combines streaming with shared state. Wired into `DevToolsKitNav` + main sidebar. - Cross-link from `docs/kit/rpc.md` so readers landing on the function- type table see the streaming alternative. - Update `skills/devframe/SKILL.md` with a Streaming Channels section (server↔client, lifecycle, Web/Node Streams interop, when-to-use matrix). Add the streaming guide to Further Reading. - Update `skills/vite-devtools-kit/SKILL.md` with a Streaming Channels section + new `ctx.rpc.streaming` row in the context table; add a detailed `references/streaming-patterns.md` with the chat-history composition example. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/config.ts | 2 + docs/kit/rpc.md | 4 + docs/kit/streaming.md | 324 ++++++++++++++++++ skills/devframe/SKILL.md | 92 +++++ skills/vite-devtools-kit/SKILL.md | 77 +++++ .../references/streaming-patterns.md | 264 ++++++++++++++ 6 files changed, 763 insertions(+) create mode 100644 docs/kit/streaming.md create mode 100644 skills/vite-devtools-kit/references/streaming-patterns.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 56a6326b..527a9a70 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -20,6 +20,7 @@ const DevToolsKitNav = [ { text: 'Remote Client', link: '/kit/remote-client' }, { text: 'RPC', link: '/kit/rpc' }, { text: 'Shared State', link: '/kit/shared-state' }, + { text: 'Streaming', link: '/kit/streaming' }, { text: 'Commands', link: '/kit/commands' }, { text: 'When Clauses', link: '/kit/when-clauses' }, { text: 'Messages & Notifications', link: '/kit/messages' }, @@ -123,6 +124,7 @@ export default extendConfig(withMermaid(defineConfig({ { text: 'Remote Client', link: '/kit/remote-client' }, { text: 'RPC', link: '/kit/rpc' }, { text: 'Shared State', link: '/kit/shared-state' }, + { text: 'Streaming', link: '/kit/streaming' }, { text: 'Commands', link: '/kit/commands' }, { text: 'When Clauses', link: '/kit/when-clauses' }, { text: 'Messages', link: '/kit/messages' }, diff --git a/docs/kit/rpc.md b/docs/kit/rpc.md index 42f5fa98..c8b0e993 100644 --- a/docs/kit/rpc.md +++ b/docs/kit/rpc.md @@ -64,6 +64,10 @@ Examples: | `action` | Side effects, mutations | Not cached | ✗ | | `event` | Emit events, no response | Not cached | ✗ | +::: tip Streaming chunks instead of single responses +For chunk-style data (LLM deltas, log lines, build progress, file uploads), reach for [streaming channels](./streaming) rather than hand-rolling `action + delta/end events`. The streaming API gives you stream IDs, cancellation, replay, and Web Streams interop for free. +::: + ### Handler Arguments Handlers can accept any serializable arguments: diff --git a/docs/kit/streaming.md b/docs/kit/streaming.md new file mode 100644 index 00000000..d607ff33 --- /dev/null +++ b/docs/kit/streaming.md @@ -0,0 +1,324 @@ +--- +outline: deep +--- + +# Streaming + +DevTools Kit ships a first-class streaming-channel API for chunk-style data flowing in either direction between server and client — chat deltas, log lines, build progress, file uploads, mic / screen-share frames. It's the same primitive as DevFrame's [streaming guide](/devframe/guide/streaming), surfaced through the Kit's Vite plugin idioms. + +Reach for streaming when you need: + +- Token-by-token rendering with low latency (LLM deltas, terminal output). +- Per-call lifecycles with cooperative cancellation. +- Replay on reconnect — a panel reopened mid-stream picks up where it left off. +- Client-to-server uploads without inventing a multipart protocol. + +For *snapshot* state that survives reconnect and syncs across panels, prefer [shared state](./shared-state) instead. + +## Overview + +```mermaid +sequenceDiagram + participant Producer as Producer (server) + participant Channel as ctx.rpc.streaming
channel + participant Browser as Subscriber (browser) + + Producer->>Channel: start({ id }) + Channel-->>Browser: chunk(seq=1, "...") + Channel-->>Browser: chunk(seq=2, "...") + Producer->>Channel: close() + Channel-->>Browser: end() +``` + +A **channel** owns a wire namespace. Each call to `channel.start()` produces an individual **stream** keyed by an id (auto-generated unless you pass one). Subscribers join by `(channelName, id)`. + +## Server-to-Client (the common case) + +### Defining a Channel + +In your `devtools.setup`, create the channel once. Channels are framework-neutral, so the same code works whether you ship via `@vitejs/devtools-kit` (Vite) or DevFrame's other adapters: + +```ts +/// +import type { Plugin } from 'vite' +import { defineRpcFunction } from '@vitejs/devtools-kit' +import * as v from 'valibot' + +export default function chatPlugin(): Plugin { + return { + name: 'my-plugin', + devtools: { + async setup(ctx) { + const channel = ctx.rpc.streaming.create('my-plugin:chat', { + replayWindow: 256, + }) + + ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:start-chat', + type: 'action', + jsonSerializable: true, + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string() }), + handler: async ({ prompt }) => { + const stream = channel.start() + ;(async () => { + for await (const token of fakeLLM(prompt, { signal: stream.signal })) { + if (stream.signal.aborted) + break + stream.write(token) + } + stream.close() + })() + return { streamId: stream.id } + }, + })) + }, + }, + } +} +``` + +The channel name follows the same `:` convention as RPC functions and shared-state keys. + +### Producing — Three Surfaces, One Stream + +The handle returned by `channel.start({ id? })` is both an imperative producer and a Web Streams `WritableStream`: + +```ts +const stream = channel.start({ id: 'optional-explicit-id' }) + +// Imperative — minimal, hand-rolled producers +stream.write(chunk) +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel +stream.id // string — what clients subscribe to + +// Web Streams — pipe any ReadableStream in: +sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) + +// Convenience — start + pipe in one call: +const stream2 = await channel.pipeFrom(sourceReadable) +``` + +Producers should poll `stream.signal.aborted` and exit cooperatively when it flips: + +```ts +for (const token of source) { + if (stream.signal.aborted) + return + stream.write(token) +} +stream.close() +``` + +#### Node.js Stream Interop + +Web Streams are the canonical surface, but Node 17+ ships free converters that bridge to `node:stream`: + +```ts +import { Readable, Writable } from 'node:stream' + +// Pipe a Node Readable into the streaming channel +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) + +// Pipe the channel out to a Node Writable +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +DevTools Kit doesn't wrap these — they're standard library, and the surface stays small. + +### Consuming — `for await` or `pipeTo` + +The client returns a reader that's both an `AsyncIterable` and exposes a `ReadableStream`: + +```ts +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' + +const rpc = await getDevToolsRpcClient() +const { streamId } = await rpc.call('my-plugin:start-chat', { + prompt: 'Hello', +}) + +const reader = rpc.streaming.subscribe('my-plugin:chat', streamId) + +// Async iterable — the simplest consumer pattern +for await (const token of reader) + appendToken(token) + +// Or pipe to a DOM-side WritableStream +await reader.readable.pipeTo(downloadWritable) + +reader.cancel() // sends cancel upstream; server stream.signal flips +``` + +Pick one surface per reader — they share a single internal queue, so concurrent draining will race. + +## Client-to-Server Uploads + +The same channel works in reverse for chunk-style uploads — file content, mic / screen-share frames, browser-side logs forwarded to disk, anything you'd otherwise hand-roll as `multipart` over HTTP. The pattern uses one normal RPC call to allocate the id, then dedicated streaming events for the chunks: + +```ts +// Server — typically inside an action handler +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + + // Process chunks asynchronously — the action returns immediately + // so the client can start uploading. + ;(async () => { + const file = createWriteStream(name) + for await (const chunk of reader) + file.write(chunk) + file.close() + })() + + return { uploadId: reader.id } + }, +})) +``` + +```ts +// Client +const { uploadId } = await rpc.call('my-plugin:upload-file', { + name: 'capture.bin', +}) +const upload = rpc.streaming.upload('my-plugin:files', uploadId) + +// Imperative +upload.write(chunk1) +upload.write(chunk2) +upload.close() + +// Or pipe a Web ReadableStream straight in: +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Lifecycle mirrors the outbound case: + +- `upload.signal` aborts when the **server** calls `reader.cancel()` (the server cancellation broadcasts an `upload-cancel` to the uploading session). +- `upload.error(err)` propagates as a thrown error inside the server's `for await`. +- If the client disconnects mid-upload, the server's `for await` exits with an `UploadDisconnected` error so consumers can clean up. + +Each `openInbound()` allocates a fresh server-allocated id and is owned by exactly one uploading session — there's no fan-in, no shared subscribers, and no replay (the producer is the client, so reconnect means restart). + +## Lifecycle and Cancellation + +| Event | Server side | Client side | +|-------|-------------|-------------| +| Producer calls `stream.close()` / `stream.error(err)` | Broadcasts `end` to subscribers | `for await` resolves (success) or throws (error) | +| Consumer calls `reader.cancel()` | `stream.signal` aborts when the **last** subscriber cancels — handlers should poll and exit | Reader marks itself cancelled; `for await` ends without iterating | +| WS disconnects | When the **last** subscriber drops, server aborts `stream.signal` | Reader stays alive; resubscribes automatically when trust is re-established | +| Panel closes mid-stream | Reader cancel cascades upstream | — | + +A stream with multiple subscribers stays alive until the last one cancels or disconnects. Producers should always make `stream.signal.aborted` part of their inner loop. + +## Replay on Reconnect + +When the channel is created with `replayWindow: N`, the server keeps a rolling buffer of the last `N` chunks per stream. On (re)subscribe, the client passes the highest sequence number it has seen, and the server replays anything newer before resuming live. + +```ts +ctx.rpc.streaming.create('my-plugin:chat', { + replayWindow: 256, // chunks to retain per stream id + closedStreamRetention: 30_000, // ms to hold closed streams for late subscribers +}) +``` + +`closedStreamRetention` defaults to 30 seconds when `replayWindow > 0` (so a panel re-opened a few seconds after a chat finishes still gets the full transcript), or 0 when replay is disabled. Set it explicitly to opt in or out. + +## Backpressure + +The client maintains a bounded queue per subscription (`highWaterMark`, default 256). When the consumer can't keep up, the **oldest** queued chunk is dropped and a [`DF0029`](/devframe/errors/DF0029) warning is logged. This is best-effort — proper transport-level backpressure isn't worth threading through the RPC layer for the streaming use cases that exist today. + +```ts +const reader = rpc.streaming.subscribe('my-plugin:chat', id, { + highWaterMark: 1024, // raise if you expect bursts the consumer can recover from +}) +``` + +If you need authoritative state rather than every intermediate value, prefer [shared state](./shared-state) — it carries Immer patches with delivery guarantees, at the cost of being structured rather than streaming. + +## Combining Streaming with Shared State + +Token-level streaming and shared-state snapshots compose naturally for chat-style UIs: + +- The conversation **log** lives in shared state (survives reloads, syncs across panels). +- Active responses use a **streaming** channel for low-latency token rendering. +- The action that starts a response appends a user message + assistant placeholder to shared state, kicks off the producer, and on producer close commits the joined content back to shared state. + +```ts +const channel = ctx.rpc.streaming.create('my-plugin:chat-tokens', { + replayWindow: 1024, +}) +const history = await ctx.rpc.sharedState.get('my-plugin:chat-history', { + initialValue: { messages: [] as ChatMessage[] }, +}) + +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:send', + type: 'action', + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string(), assistantId: v.string() }), + handler: async ({ prompt }) => { + const stream = channel.start() + const assistantId = crypto.randomUUID() + + history.mutate((draft) => { + draft.messages.push({ + id: crypto.randomUUID(), + role: 'user', + content: prompt, + }) + draft.messages.push({ + id: assistantId, + role: 'assistant', + content: '', + streamId: stream.id, + }) + }) + + let acc = '' + ;(async () => { + for await (const token of fakeLLM(prompt, { signal: stream.signal })) { + if (stream.signal.aborted) + break + stream.write(token) + acc += token + } + stream.close() + // Commit final content; clients now read the message from + // shared state and drop the live overlay. + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + } + }) + })() + + return { streamId: stream.id, assistantId } + }, +})) +``` + +A working version of this pattern lives in [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). + +## When to Use Streaming vs Events vs Shared State + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds (LLM deltas, build logs) | Notifications without payload (`refresh`, `clear`) | Long-lived UI state (selections, panel layout) | +| Per-call lifecycles with cancellation | Cross-cutting signals broadcast to all clients | Reactive snapshots that survive reconnect | +| Replay on reconnect | Fire-and-forget signaling | Diff-based sync between clients | +| Client-to-server uploads (files, mic frames) | | | + +## Reference + +- API surface: `RpcStreamingHost`, `RpcStreamingChannel`, `StreamSink`, `StreamReader` — re-exported from `@vitejs/devtools-kit`. +- Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). +- Errors: [`DF0029`](/devframe/errors/DF0029) (overflow), [`DF0030`](/devframe/errors/DF0030) (unknown stream id), [`DF0031`](/devframe/errors/DF0031) (write to closed stream), [`DF0032`](/devframe/errors/DF0032) (channel name collision). diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 8b96ccf1..ef241a4d 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -149,6 +149,97 @@ state.mutate((draft) => { - Mutations round-trip to all clients; the host tracks `syncIds` to avoid replay loops. - Prefer shared state over ad-hoc RPC events for UI that must reappear after reconnect. +## Streaming channels + +For chunk-style data flowing in either direction — LLM deltas, log tails, build progress, file uploads, mic / screen-share frames — use a streaming channel instead of inventing `action + delta/end` events. The same `channel` object handles both directions: + +```ts +const channel = ctx.rpc.streaming.create('my-inspector:tokens', { + replayWindow: 256, // server keeps last N chunks per stream id + closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers +}) +``` + +### Server-to-client (the common case) + +```ts +// Server — typically inside an action handler that returns the stream id +const stream = channel.start({ id?: string }) +stream.write(token) // imperative +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel or all subscribers drop +stream.writable // WritableStream for `pipeTo`-style consumption + +// Convenience — start + pipe in one call: +await channel.pipeFrom(sourceReadable, { id: 'optional' }) + +// Client +const reader = rpc.streaming.subscribe('my-inspector:tokens', streamId) +for await (const token of reader) renderToken(token) +// Or: reader.readable.pipeTo(domWritable) +reader.cancel() // server `stream.signal` aborts +``` + +### Client-to-server uploads + +The same channel exposes `openInbound()` for the server side of a client→server upload. Pair it with a normal action that returns the id: + +```ts +// Server +ctx.rpc.register(defineRpcFunction({ + name: 'my-inspector:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + ;(async () => { + for await (const chunk of reader) saveChunk(chunk) + })() + return { uploadId: reader.id } + }, +})) + +// Client +const { uploadId } = await rpc.call('my-inspector:upload-file', { name: 'foo' }) +const upload = rpc.streaming.upload('my-inspector:files', uploadId) +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Client disconnect surfaces as `UploadDisconnected` to the server's `for await`. Server-side `reader.cancel()` broadcasts `upload-cancel` to the uploading session, flipping `upload.signal`. + +### Lifecycle + +| Event | Server | Client | +|-------|--------|--------| +| `stream.close()` / `stream.error(err)` | broadcasts `end` to subscribers | `for await` resolves / throws | +| `reader.cancel()` (last subscriber) | `stream.signal` aborts | reader marked cancelled | +| WS disconnects (last subscriber drops) | `stream.signal` aborts | reader auto-resubscribes after re-trust | + +Producers should always poll `stream.signal.aborted` and exit cooperatively. + +### Web / Node Streams interop + +Web Streams are the canonical surface. Node 17+ ships free converters: + +```ts +import { Readable, Writable } from 'node:stream' + +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +### When to use streaming vs events vs shared state + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds, uploads | Notifications without payload (`refresh`) | Long-lived UI state that survives reconnect | +| Per-call lifecycles with cancellation | Cross-cutting fire-and-forget signals | Diff-based sync between clients | +| Replay on reconnect | | | + +For chat-style UIs that combine both: keep the **conversation log** in shared state (survives reconnects), and use a streaming channel for **active responses**. The action that starts a response appends a placeholder to shared state; on producer close, commit the joined content back to shared state. Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). + ## Dock entries Five entry types: `iframe` (full panel), `action` (client script on click), `custom-render` (mount into panel DOM), `launcher` (setup card + server callback), `json-render` (UI from a JSON spec, zero client code). @@ -350,6 +441,7 @@ All of the above has a dedicated page at [docs.devtools.vite.dev/devframe](https - [Adapters](https://devtools.vite.dev/devframe/adapters) — full reference for all seven adapters - [RPC](https://devtools.vite.dev/devframe/rpc) — types, schema, broadcasts, dumps - [Shared State](https://devtools.vite.dev/devframe/shared-state) — patches, events, client-side mutation +- [Streaming](https://devtools.vite.dev/devframe/streaming) — chunked feeds, uploads, replay, Web/Node Streams interop - [Dock System](https://devtools.vite.dev/devframe/dock-system) — every entry type + remote docks - [Commands](https://devtools.vite.dev/devframe/commands) — palette, keybindings, sub-commands - [When Clauses](https://devtools.vite.dev/devframe/when-clauses) — syntax, context, type-safe wrappers diff --git a/skills/vite-devtools-kit/SKILL.md b/skills/vite-devtools-kit/SKILL.md index 115c03a3..ca40c62d 100644 --- a/skills/vite-devtools-kit/SKILL.md +++ b/skills/vite-devtools-kit/SKILL.md @@ -22,6 +22,7 @@ A DevTools plugin extends a Vite plugin with a `devtools.setup(ctx)` hook. The c | `ctx.views` | Host static files for UI | | `ctx.rpc` | Register RPC functions, broadcast to clients | | `ctx.rpc.sharedState` | Synchronized server-client state | +| `ctx.rpc.streaming` | Streaming channels — chunk-style server↔client data with cancellation, replay, Web Streams interop | | `ctx.messages` | Emit structured message entries and toast notifications | | `ctx.diagnostics` | Structured diagnostics host (logs-sdk) — register custom error codes | | `ctx.terminals` | Spawn and manage child processes with streaming terminal output | @@ -479,6 +480,81 @@ state.on('updated', (newState) => { }) ``` +## Streaming Channels + +For chunk-style data flowing in either direction (LLM deltas, build logs, file uploads), use a streaming channel rather than hand-rolling `action + delta/end events`. The same `channel` handles server→client and client→server. + +### Server-to-Client + +```ts +ctx.rpc.streaming.create('my-plugin:tokens', { + replayWindow: 256, // chunks retained per stream id +}) + +// Inside an action handler: +const stream = channel.start() +;(async () => { + for (const token of fakeLLM(prompt)) { + if (stream.signal.aborted) + return + stream.write(token) + } + stream.close() +})() +return { streamId: stream.id } +``` + +```ts +// Client +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' + +const rpc = await getDevToolsRpcClient() +const { streamId } = await rpc.call('my-plugin:start', { prompt }) +const reader = rpc.streaming.subscribe('my-plugin:tokens', streamId) +for await (const token of reader) + appendToken(token) +reader.cancel() // server stream.signal aborts +``` + +The reader is also `.readable: ReadableStream` for `pipeTo` consumption. The sink is also `.writable: WritableStream` — `await channel.pipeFrom(readableSource)` is the one-call shortcut. + +### Client-to-Server Uploads + +```ts +// Server +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:upload-file', + type: 'action', + handler: async ({ name }) => { + const reader = channel.openInbound() + ;(async () => { + const file = createWriteStream(name) + for await (const chunk of reader) + file.write(chunk) + file.close() + })() + return { uploadId: reader.id } + }, +})) + +// Client +const { uploadId } = await rpc.call('my-plugin:upload-file', { name }) +const upload = rpc.streaming.upload('my-plugin:files', uploadId) +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +`upload.signal` aborts when the server calls `reader.cancel()`. Client disconnect surfaces as `UploadDisconnected` in the server's `for await`. + +### When to use streaming + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds, uploads | Notifications without payload | Long-lived UI state | +| Per-call lifecycles + cancellation | Cross-cutting fire-and-forget | Diff-based snapshots | +| Replay on reconnect | | | + +For chat-style UIs, combine: keep the **conversation log** in shared state and stream **active responses** through the channel. Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). Full reference: [Streaming Patterns](./references/streaming-patterns.md). + ## Client Scripts For action buttons and custom renderers: @@ -553,6 +629,7 @@ Real-world example plugins in the repo — reference their code structure and pa - [RPC Patterns](./references/rpc-patterns.md) - Advanced RPC patterns and type utilities - [Dock Entry Types](./references/dock-entry-types.md) - Detailed dock configuration options - [Shared State Patterns](./references/shared-state-patterns.md) - Framework integration examples +- [Streaming Patterns](./references/streaming-patterns.md) - Streaming channels, uploads, replay, chat-history pattern - [Project Structure](./references/project-structure.md) - Recommended file organization - [JSON Render Patterns](./references/json-render-patterns.md) - Server-side JSON specs, components, state binding - [Terminals Patterns](./references/terminals-patterns.md) - Child processes, custom streams, session lifecycle diff --git a/skills/vite-devtools-kit/references/streaming-patterns.md b/skills/vite-devtools-kit/references/streaming-patterns.md new file mode 100644 index 00000000..4fa2101c --- /dev/null +++ b/skills/vite-devtools-kit/references/streaming-patterns.md @@ -0,0 +1,264 @@ +# Streaming Patterns + +Chunk-style server↔client data flow with cancellation, replay, and Web Streams interop. Use streaming when a feature is *delta-shaped* (LLM tokens, log lines, build progress, file uploads, mic frames). Use [shared state](./shared-state-patterns.md) when the feature is *snapshot-shaped* (selections, panel layout, cached lists). + +## Core API + +```ts +// Server (in devtools.setup) +const channel = ctx.rpc.streaming.create('my-plugin:tokens', { + replayWindow: 256, // chunks retained per stream id + closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers +}) + +// Client +const reader = rpc.streaming.subscribe(channelName, streamId) +``` + +A **channel** owns a wire namespace. Each `channel.start()` produces an individual **stream** keyed by an id. + +## Server-to-Client (Server Produces) + +### Action that returns a stream id + +The typical pattern: a normal action allocates the id, kicks off a background producer, and returns the id so the client can subscribe. + +```ts +import { defineRpcFunction } from '@vitejs/devtools-kit' +import * as v from 'valibot' + +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:start-chat', + type: 'action', + jsonSerializable: true, + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string() }), + handler: async ({ prompt }) => { + const stream = channel.start() + ;(async () => { + try { + for await (const token of someLLM(prompt, { signal: stream.signal })) { + if (stream.signal.aborted) + break + stream.write(token) + } + stream.close() + } + catch (err) { + stream.error(err) + } + })() + return { streamId: stream.id } + }, +})) +``` + +### Three producer surfaces, one stream + +```ts +const stream = channel.start({ id: 'optional-explicit-id' }) + +// Imperative +stream.write(chunk) +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel +stream.id // string — what clients subscribe to + +// Web Streams — pipe any ReadableStream in: +sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) + +// One-call shortcut: +const stream2 = await channel.pipeFrom(sourceReadable) +``` + +### Two consumer surfaces, one reader + +```ts +const reader = rpc.streaming.subscribe('my-plugin:tokens', streamId) + +// Async iterable — simplest pattern +for await (const token of reader) + appendToken(token) + +// Or pipe to a DOM-side WritableStream +await reader.readable.pipeTo(downloadWritable) + +reader.cancel() // server stream.signal aborts +``` + +Pick one surface per reader — they share a single internal queue, so concurrent draining will race. + +## Client-to-Server Uploads (Client Produces) + +The same channel exposes `openInbound()` for the server side of an upload. Pair it with a normal action that returns the id: + +```ts +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + ;(async () => { + const file = createWriteStream(name) + try { + for await (const chunk of reader) + file.write(chunk) + } + finally { + file.close() + } + })() + return { uploadId: reader.id } + }, +})) +``` + +```ts +// Client +const { uploadId } = await rpc.call('my-plugin:upload-file', { name: 'capture.bin' }) +const upload = rpc.streaming.upload('my-plugin:files', uploadId) + +// Imperative +upload.write(chunk1) +upload.write(chunk2) +upload.close() + +// Web Streams +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Lifecycle: + +- `upload.signal` aborts when the server calls `reader.cancel()` (server cancellation broadcasts an `upload-cancel` to the uploading session). +- `upload.error(err)` propagates as a thrown error inside the server's `for await`. +- Client disconnect surfaces as `UploadDisconnected` in the server's `for await`. + +Each `openInbound()` is owned by one uploading session — no fan-in. + +## Lifecycle Reference + +| Event | Server side | Client side | +|-------|-------------|-------------| +| `stream.close()` / `stream.error(err)` | broadcasts `end` to subscribers | `for await` resolves / throws | +| `reader.cancel()` (last subscriber) | `stream.signal` aborts | reader marked cancelled | +| WS disconnect (last subscriber drops) | `stream.signal` aborts | reader auto-resubscribes after re-trust | + +Producers should always poll `stream.signal.aborted` and exit cooperatively. + +## Replay on Reconnect + +Set `replayWindow: N` to keep the last N chunks per stream. On (re)subscribe, the client passes the highest `seq` it has seen, and the server replays anything newer before resuming live: + +```ts +const channel = ctx.rpc.streaming.create('my-plugin:tokens', { + replayWindow: 1024, + closedStreamRetention: 30_000, // hold finished streams for late subscribers +}) +``` + +`closedStreamRetention` defaults to 30 seconds when `replayWindow > 0`, or 0 when replay is disabled. + +## Backpressure + +Client maintains a bounded queue per subscription (`highWaterMark`, default 256). Overflow drops the **oldest** chunk and emits a `DF0029` warning: + +```ts +rpc.streaming.subscribe('my-plugin:tokens', id, { highWaterMark: 1024 }) +``` + +If you need authoritative state rather than every intermediate value, prefer shared state. + +## Web / Node Streams Interop + +Web Streams are the canonical surface. Node 17+ ships free converters: + +```ts +import { Readable, Writable } from 'node:stream' + +// Pipe a Node Readable into the streaming channel +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) + +// Pipe the channel out to a Node Writable +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +DevTools Kit doesn't wrap these — they're standard library. + +## Combining Streaming with Shared State + +For chat-style UIs that need both real-time deltas AND persistent history, compose the two primitives: + +- The **conversation log** lives in shared state — survives reloads, syncs across panels. +- Active responses use a **streaming channel** for low-latency rendering. +- The action that starts a response appends a placeholder to shared state, kicks off the producer, and on producer close commits the joined content back to shared state. + +```ts +const tokens = ctx.rpc.streaming.create('my-plugin:chat-tokens', { + replayWindow: 1024, +}) +const history = await ctx.rpc.sharedState.get('my-plugin:chat-history', { + initialValue: { messages: [] as ChatMessage[] }, +}) + +ctx.rpc.register(defineRpcFunction({ + name: 'my-plugin:send', + type: 'action', + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string(), assistantId: v.string() }), + handler: async ({ prompt }) => { + const stream = tokens.start() + const assistantId = crypto.randomUUID() + + history.mutate((draft) => { + draft.messages.push({ id: crypto.randomUUID(), role: 'user', content: prompt }) + draft.messages.push({ + id: assistantId, + role: 'assistant', + content: '', + streamId: stream.id, + }) + }) + + let acc = '' + ;(async () => { + for await (const token of someLLM(prompt, { signal: stream.signal })) { + if (stream.signal.aborted) + break + stream.write(token) + acc += token + } + stream.close() + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + } + }) + })() + + return { streamId: stream.id, assistantId } + }, +})) +``` + +Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat) — Preact UI rendering messages from shared state, with live token overlays for in-flight assistant responses. + +## When to Use Streaming vs Events vs Shared State + +| Streaming | `event`-typed RPC | Shared state | +|-----------|-------------------|--------------| +| Token / chunk feeds (LLM deltas, build logs) | Notifications without payload (`refresh`, `clear`) | Long-lived UI state (selections, panel layout) | +| Per-call lifecycles with cancellation | Cross-cutting signals to all clients | Reactive snapshots that survive reconnect | +| Replay on reconnect | Fire-and-forget signaling | Diff-based sync between clients | +| Client-to-server uploads | | | + +## Errors + +- [`DF0029`](https://devtools.vite.dev/devframe/errors/DF0029) — buffer overflow (consumer too slow) +- [`DF0030`](https://devtools.vite.dev/devframe/errors/DF0030) — unknown stream id +- [`DF0031`](https://devtools.vite.dev/devframe/errors/DF0031) — write to closed stream +- [`DF0032`](https://devtools.vite.dev/devframe/errors/DF0032) — channel name collision