diff --git a/.github/workflows/ecosystem-ci.yml b/.github/workflows/ecosystem-ci.yml index 5a6dd64..17df18a 100644 --- a/.github/workflows/ecosystem-ci.yml +++ b/.github/workflows/ecosystem-ci.yml @@ -27,7 +27,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test:ecosystem env: - ECOSYSTEM_DEVTOOLS_REF: ${{ inputs.ref }} + ECOSYSTEM_DEVFRAME_REF: ${{ inputs.ref }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - if: failure() uses: actions/upload-artifact@v4 diff --git a/AGENTS.md b/AGENTS.md index 3daeef7..2fb3123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ **`devframe`** is the framework-neutral container for one devtool integration, portable across viewers. Build a single tool (its RPC, its SPA, its diagnostics, its CLI/build/spa/embedded outputs) without caring how it'll be displayed. A devframe app runs standalone (CLI, static deploy, embedded SPA) just as well as it mounts inside a hub. +**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI — implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. See `examples/minimal-vite-devframe-hub/` for a working ~120-line Vite host demonstrating the protocol end to end. + ## Stack & Structure ESM TypeScript library. Bundled with `tsdown`. Tested with `vitest`. pnpm workspaces with catalog dependencies (`pnpm-workspace.yaml`); workspace globs reserve `playground`, `docs`, `packages/*`, `examples/*` for future additions. @@ -50,6 +52,16 @@ All node-side warnings and errors use structured diagnostics via [`nostics`](htt Prefix: **`DF`**. Codes are sequential 4-digit numbers (e.g. `DF0033`). Check the existing diagnostics file to find the next available number. +Range allocation: +- `DF00xx–DF07xx` — `devframe` core (RPC, host, storage, streams, …) +- `DF80xx–DF89xx` — `@devframes/hub`. Sub-ranges: + - `DF80xx` — hub context / lifecycle + - `DF81xx` — docks + - `DF82xx` — terminals + - `DF83xx` — messages + - `DF84xx` — commands + - `DF85xx` — built-in RPC commands + ### Adding a new error 1. **Define the code** in the appropriate `diagnostics.ts`: diff --git a/README.md b/README.md index 1c4cbb9..c505d82 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

-Framework-neutral foundation for building generic DevTools. +Framework-neutral foundation for building devframes.

diff --git a/alias.ts b/alias.ts index d0cb424..e1f0e42 100644 --- a/alias.ts +++ b/alias.ts @@ -14,7 +14,7 @@ export const alias = { 'devframe/rpc': r('devframe/src/rpc'), 'devframe/types': r('devframe/src/types/index.ts'), 'devframe/node/auth': r('devframe/src/node/auth/index.ts'), - 'devframe/node/internal': r('devframe/src/node/internal/index.ts'), + 'devframe/node/hub-internals': r('devframe/src/node/hub-internals/index.ts'), 'devframe/node': r('devframe/src/node/index.ts'), 'devframe/constants': r('devframe/src/constants.ts'), 'devframe/utils/colors': r('devframe/src/utils/colors.ts'), @@ -36,6 +36,11 @@ export const alias = { 'devframe/helpers/vite': r('devframe/src/helpers/vite.ts'), 'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'), 'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'), + '@devframes/hub/client': r('hub/src/client/index.ts'), + '@devframes/hub/constants': r('hub/src/constants.ts'), + '@devframes/hub/node': r('hub/src/node/index.ts'), + '@devframes/hub/types': r('hub/src/types/index.ts'), + '@devframes/hub': r('hub/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d129870..034b98b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -28,6 +28,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] { { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, { text: 'Client', link: `${prefix}/guide/client` }, { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, + { text: 'Hub (multi-tool)', link: `${prefix}/guide/hub` }, { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, ] } @@ -98,7 +99,7 @@ export function devframeNav(prefix = ''): DefaultTheme.NavItem[] { export default withMermaid(defineConfig({ title: 'Devframe', - description: 'Framework-neutral foundation for building generic DevTools — RPC layer, hosts, and adapters.', + description: 'Framework-neutral foundation for building generic devframes — RPC layer, hosts, and adapters.', head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }], ['link', { rel: 'apple-touch-icon', href: '/logo.svg' }], diff --git a/docs/adapters/cli.md b/docs/adapters/cli.md index 076108c..3e363f8 100644 --- a/docs/adapters/cli.md +++ b/docs/adapters/cli.md @@ -26,11 +26,11 @@ Running the resulting binary: my-devframe # dev server at http://localhost:9999/ my-devframe --port 8080 my-devframe build --out-dir dist-static -my-devframe build --out-dir dist-static --base /devtools/ +my-devframe build --out-dir dist-static --base /devframe/ my-devframe mcp # stdio MCP server (experimental) ``` -Standalone CLI serves the SPA at `/` by default. The `/__devtools/` prefix is for *hosted* adapters where devframe mounts alongside an existing app — see [Mount paths](./#mount-paths). +Standalone CLI serves the SPA at `/` by default. The `/__devframe/` prefix is for *hosted* adapters where devframe mounts alongside an existing app — see [Mount paths](./#mount-paths). ## Options diff --git a/docs/adapters/embedded.md b/docs/adapters/embedded.md index 5d2ae0b..ef9bf64 100644 --- a/docs/adapters/embedded.md +++ b/docs/adapters/embedded.md @@ -15,6 +15,6 @@ await createEmbedded(devframe, { ctx: existingCtx }) | Option | Required | Description | |--------|----------|-------------| -| `ctx` | ✓ | Target `DevToolsNodeContext` the devframe is registered into. | +| `ctx` | ✓ | Target `DevframeNodeContext` the devframe is registered into. | Useful when a host loads devframes based on runtime conditions (feature flags, user opt-in, dynamic discovery) rather than static config. diff --git a/docs/errors/DF0006.md b/docs/errors/DF0006.md index fa848d0..d4d6076 100644 --- a/docs/errors/DF0006.md +++ b/docs/errors/DF0006.md @@ -18,4 +18,4 @@ Register the function with `ctx.rpc.register(defineRpcFunction({ name }))` befor ## Source -- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `RpcFunctionsHost.invokeLocal()` throws `DF0006` when the requested method has not been registered on this host. +- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `RpcFunctionsHost.invokeLocal()` throws `DF0006` when the requested method has not been registered on this host. diff --git a/docs/errors/DF0007.md b/docs/errors/DF0007.md index 7cc5e40..d95e9cd 100644 --- a/docs/errors/DF0007.md +++ b/docs/errors/DF0007.md @@ -6,7 +6,7 @@ outline: deep ## Message -> AsyncLocalStorage is not set, it likely to be an internal bug of the DevTools foundation +> AsyncLocalStorage is not set, it likely to be an internal bug of the Devframe foundation ## Cause @@ -18,4 +18,4 @@ Only call `getCurrentRpcSession()` from RPC handlers executed by the server. Rep ## Source -- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `getCurrentRpcSession()` throws `DF0007` when called outside the RPC dispatch async context. +- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `getCurrentRpcSession()` throws `DF0007` when called outside the RPC dispatch async context. diff --git a/docs/errors/DF0008.md b/docs/errors/DF0008.md index 57db674..b423901 100644 --- a/docs/errors/DF0008.md +++ b/docs/errors/DF0008.md @@ -10,7 +10,7 @@ outline: deep ## Cause -`DevToolsViewHost.hostStatic()` was asked to mount a directory that doesn't exist on disk. +`DevframeViewHost.hostStatic()` was asked to mount a directory that doesn't exist on disk. ## Fix @@ -18,4 +18,4 @@ Verify the `distDir` path resolves correctly (run your SPA build first, and chec ## Source -- [`packages/devframe/src/node/host-views.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-views.ts) — `DevToolsViewHost.hostStatic()` throws `DF0008` when the resolved `distDir` does not exist on disk. +- [`packages/devframe/src/node/host-views.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-views.ts) — `DevframeViewHost.hostStatic()` throws `DF0008` when the resolved `distDir` does not exist on disk. diff --git a/docs/errors/DF0012.md b/docs/errors/DF0012.md index d4f5ae6..7706726 100644 --- a/docs/errors/DF0012.md +++ b/docs/errors/DF0012.md @@ -18,4 +18,4 @@ Delete the file to reset to defaults, or investigate how it became malformed. ## Source -- [`packages/devframe/src/node/storage.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/storage.ts) — `createStorage()` catches `JSON.parse` errors and logs `DF0012` (with the cause attached) before falling back to `initialValue`. +- [`packages/devframe/src/node/storage.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/storage.ts) — `createStorage()` catches `JSON.parse` errors and logs `DF0012` (with the cause attached) before falling back to `initialValue`. diff --git a/docs/errors/DF0013.md b/docs/errors/DF0013.md index 00a94fd..661b593 100644 --- a/docs/errors/DF0013.md +++ b/docs/errors/DF0013.md @@ -18,4 +18,4 @@ Pass `initialValue` on the first call: `ctx.rpc.sharedState.get(key, { initialVa ## Source -- [`packages/devframe/src/node/rpc-shared-state.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-shared-state.ts) — `RpcSharedStateHost.get()` throws `DF0013` when neither an existing entry nor an `initialValue` is provided for a key. +- [`packages/devframe/src/node/rpc-shared-state.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/rpc-shared-state.ts) — `RpcSharedStateHost.get()` throws `DF0013` when neither an existing entry nor an `initialValue` is provided for a key. diff --git a/docs/errors/DF0014.md b/docs/errors/DF0014.md index bef3792..4798c8e 100644 --- a/docs/errors/DF0014.md +++ b/docs/errors/DF0014.md @@ -50,4 +50,4 @@ If you didn't intend for this function to be agent-exposed, remove the `agent` f ## Source -- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — agent registration throws `DF0014` when a tool's `agent.description` is missing or empty. +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — agent registration throws `DF0014` when a tool's `agent.description` is missing or empty. diff --git a/docs/errors/DF0015.md b/docs/errors/DF0015.md index 35ae3e5..26562df 100644 --- a/docs/errors/DF0015.md +++ b/docs/errors/DF0015.md @@ -34,4 +34,4 @@ ctx.agent.registerTool({ id: 'my-tool', /* new config */ }) ## Source -- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerTool()` throws `DF0015` when the tool id collides with an existing agent tool or an RPC function's `agent` exposure. +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerTool()` throws `DF0015` when the tool id collides with an existing agent tool or an RPC function's `agent` exposure. diff --git a/docs/errors/DF0016.md b/docs/errors/DF0016.md index 1fce205..de41517 100644 --- a/docs/errors/DF0016.md +++ b/docs/errors/DF0016.md @@ -22,4 +22,4 @@ Pick a distinct id or unregister the existing resource first via the handle retu ## Source -- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerResource()` throws `DF0016` when the resource id is already registered on the host. +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerResource()` throws `DF0016` when the resource id is already registered on the host. diff --git a/docs/errors/DF0017.md b/docs/errors/DF0017.md index 38aa747..8d60b7c 100644 --- a/docs/errors/DF0017.md +++ b/docs/errors/DF0017.md @@ -28,4 +28,4 @@ The agent-native surface is experimental and may change without a major version ## Source -- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`. +- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`. diff --git a/docs/errors/DF0019.md b/docs/errors/DF0019.md index e084aaf..ec950ab 100644 --- a/docs/errors/DF0019.md +++ b/docs/errors/DF0019.md @@ -49,4 +49,4 @@ defineRpcFunction({ ## Source -- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0019` when a definition has `agent` set but is not declared `jsonSerializable: true`. +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0019` when a definition has `agent` set but is not declared `jsonSerializable: true`. diff --git a/docs/errors/DF0020.md b/docs/errors/DF0020.md index 36395f2..09a883b 100644 --- a/docs/errors/DF0020.md +++ b/docs/errors/DF0020.md @@ -53,4 +53,4 @@ Or convert the payload to a JSON-safe shape (e.g. an array of entries, an ISO st ## Source -- [`packages/devframe/src/rpc/serialization.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/serialization.ts) — the strict JSON serializer throws `DF0020` (with the offending path and runtime type) when a `jsonSerializable: true` payload contains a non-JSON value. +- [`packages/devframe/src/rpc/serialization.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/serialization.ts) — the strict JSON serializer throws `DF0020` (with the offending path and runtime type) when a `jsonSerializable: true` payload contains a non-JSON value. diff --git a/docs/errors/DF0021.md b/docs/errors/DF0021.md index 47159f9..eac91e2 100644 --- a/docs/errors/DF0021.md +++ b/docs/errors/DF0021.md @@ -22,4 +22,4 @@ ctx.rpc.register(defineRpcFunction({ name: 'my-plugin:fn', handler: () => 1 }), ## Source -- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0021` when an RPC name is already registered and `force` is not set. +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0021` when an RPC name is already registered and `force` is not set. diff --git a/docs/errors/DF0022.md b/docs/errors/DF0022.md index 5941f87..47cc11a 100644 --- a/docs/errors/DF0022.md +++ b/docs/errors/DF0022.md @@ -18,4 +18,4 @@ Call `ctx.rpc.register()` first, or pass `force: true` to `update()` to register ## Source -- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.update()` throws `DF0022` when the named function was never registered. +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.update()` throws `DF0022` when the named function was never registered. diff --git a/docs/errors/DF0023.md b/docs/errors/DF0023.md index 5937e06..975972e 100644 --- a/docs/errors/DF0023.md +++ b/docs/errors/DF0023.md @@ -18,4 +18,4 @@ Confirm the function name matches a registration. RPC names are namespaced — t ## Source -- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — collector `get()`/lookup paths throw `DF0023` when consumers ask for a function that has not been registered. +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — collector `get()`/lookup paths throw `DF0023` when consumers ask for a function that has not been registered. diff --git a/docs/errors/DF0024.md b/docs/errors/DF0024.md index 07ca9b7..4ecb3cb 100644 --- a/docs/errors/DF0024.md +++ b/docs/errors/DF0024.md @@ -18,5 +18,5 @@ Add either `handler: ...` directly on the definition, or `setup: ctx => ({ handl ## Source -- [`packages/devframe/src/rpc/handler.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/handler.ts) — invocation throws `DF0024` when neither `handler` nor a `setup` returning `{ handler }` is provided. -- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — dump generation also requires a handler and throws `DF0024` if the definition is incomplete. +- [`packages/devframe/src/rpc/handler.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/handler.ts) — invocation throws `DF0024` when neither `handler` nor a `setup` returning `{ handler }` is provided. +- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — dump generation also requires a handler and throws `DF0024` if the definition is incomplete. diff --git a/docs/errors/DF0025.md b/docs/errors/DF0025.md index fa8285b..e0af4b0 100644 --- a/docs/errors/DF0025.md +++ b/docs/errors/DF0025.md @@ -18,4 +18,4 @@ Re-run `createBuild` to regenerate the dump, or check that the call site uses th ## Source -- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — the static-mode dump resolver throws `DF0025` when a client calls a function name that is not present in the baked dump store. +- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — the static-mode dump resolver throws `DF0025` when a client calls a function name that is not present in the baked dump store. diff --git a/docs/errors/DF0026.md b/docs/errors/DF0026.md index cccd666..f7f4627 100644 --- a/docs/errors/DF0026.md +++ b/docs/errors/DF0026.md @@ -28,4 +28,4 @@ defineRpcFunction({ ## Source -- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — the static-mode dump resolver throws `DF0026` when none of the pre-computed inputs matches the call's args and no `fallback` was configured. +- [`packages/devframe/src/rpc/dump/index.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/dump/index.ts) — the static-mode dump resolver throws `DF0026` when none of the pre-computed inputs matches the call's args and no `fallback` was configured. diff --git a/docs/errors/DF0027.md b/docs/errors/DF0027.md index 099ed35..d9da4c3 100644 --- a/docs/errors/DF0027.md +++ b/docs/errors/DF0027.md @@ -18,4 +18,4 @@ Drop the `dump` field, or change the function `type` to `'static'` / `'query'` i ## Source -- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0027` when a `dump` field is attached to an `'action'` or `'event'` function. +- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0027` when a `dump` field is attached to an `'action'` or `'event'` function. diff --git a/docs/errors/DF0028.md b/docs/errors/DF0028.md index a0f840e..d9758fd 100644 --- a/docs/errors/DF0028.md +++ b/docs/errors/DF0028.md @@ -18,4 +18,4 @@ Remove `snapshot: true`, or change the function `type` to `'query'`. ## Source -- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0028` when `snapshot: true` is set on a function whose type is not `'query'`. +- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0028` when `snapshot: true` is set on a function whose type is not `'query'`. diff --git a/docs/errors/DF0029.md b/docs/errors/DF0029.md index a4111b4..f5c41fc 100644 --- a/docs/errors/DF0029.md +++ b/docs/errors/DF0029.md @@ -22,4 +22,4 @@ This is a soft warning — the stream keeps running and remaining chunks still f ## Source -- [`packages/devframe/src/client/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/client/rpc-streaming.ts) — the client subscription queue logs `DF0029` (with the dropped chunk count) when buffered chunks exceed `highWaterMark`. +- [`packages/devframe/src/client/rpc-streaming.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/client/rpc-streaming.ts) — the client subscription queue logs `DF0029` (with the dropped chunk count) when buffered chunks exceed `highWaterMark`. diff --git a/docs/errors/DF0030.md b/docs/errors/DF0030.md index b7d0d28..2065fcd 100644 --- a/docs/errors/DF0030.md +++ b/docs/errors/DF0030.md @@ -20,4 +20,4 @@ A client subscribed to a stream id that the server-side channel doesn't know abo ## Source -- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — the streaming subscribe/unsubscribe paths log `DF0030` when a client references an `id` that no producer has started (and no replay buffer covers). +- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — the streaming subscribe/unsubscribe paths log `DF0030` when a client references an `id` that no producer has started (and no replay buffer covers). diff --git a/docs/errors/DF0031.md b/docs/errors/DF0031.md index 7a80000..6e9eb10 100644 --- a/docs/errors/DF0031.md +++ b/docs/errors/DF0031.md @@ -33,4 +33,4 @@ catch (err) { ## Source -- [`packages/devframe/src/utils/streaming-channel.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/utils/streaming-channel.ts) — `stream.write()` throws `DF0031` when called after the stream has been closed, errored, or aborted by the consumer. +- [`packages/devframe/src/utils/streaming-channel.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/utils/streaming-channel.ts) — `stream.write()` throws `DF0031` when called after the stream has been closed, errored, or aborted by the consumer. diff --git a/docs/errors/DF0032.md b/docs/errors/DF0032.md index 109fcca..4171fee 100644 --- a/docs/errors/DF0032.md +++ b/docs/errors/DF0032.md @@ -19,4 +19,4 @@ Two calls to `ctx.rpc.streaming.create(name, ...)` used the same channel name. E ## Source -- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — `ctx.rpc.streaming.create()` throws `DF0032` when the requested channel name is already registered on the context. +- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — `ctx.rpc.streaming.create()` throws `DF0032` when the requested channel name is already registered on the context. diff --git a/docs/errors/DF0033.md b/docs/errors/DF0033.md index 8bee827..e2468e4 100644 --- a/docs/errors/DF0033.md +++ b/docs/errors/DF0033.md @@ -26,4 +26,4 @@ This is a soft warning — the surrounding Vite dev server keeps running, but th ## Source -- [`packages/devframe/src/helpers/vite.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/helpers/vite.ts) — `viteDevBridge({ devMiddleware })` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`. +- [`packages/devframe/src/helpers/vite.ts`](https://github.com/vitejs/devframe/blob/main/devframe/packages/devframe/src/helpers/vite.ts) — `viteDevBridge({ devMiddleware })` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`. diff --git a/docs/errors/DF8100.md b/docs/errors/DF8100.md new file mode 100644 index 0000000..b9c46ca --- /dev/null +++ b/docs/errors/DF8100.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8100: Dock Already Registered + +## Message + +> Dock with id "`{id}`" is already registered + +## Cause + +`ctx.docks.register(view)` was called with an `id` that another dock already owns. Dock ids are unique per hub context, so the second registration would silently override the first if allowed. + +## Fix + +- Pick a different dock id (best practice: namespace under your tool's id, e.g. `my-tool:overview`). +- If overwriting is intentional (e.g. a hot-reload scenario), pass `force: true`: `ctx.docks.register(view, true)`. + +## Source + +- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts) — `DevframeDocksHost.register()` throws when `views.has(view.id) && !force`. diff --git a/docs/errors/DF8101.md b/docs/errors/DF8101.md new file mode 100644 index 0000000..b04270c --- /dev/null +++ b/docs/errors/DF8101.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8101: Cannot Change Dock Id + +## Message + +> Cannot change the id of a dock. Use register() to add new docks. + +## Cause + +The `update` handle returned by `ctx.docks.register(view)` received a patch whose `id` differs from the original. Dock ids are immutable post-registration — they key the dock list and any shared-state references. + +## Fix + +- Drop `id` from your patch; only pass the fields you actually want to mutate. +- To replace one dock with a different one: call `ctx.docks.register(newView)` (or `register(newView, true)` to overwrite an existing id). + +## Source + +- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts) — `DevframeDocksHost.register()` returns an `update` callable that throws this when the patch carries a different `id`. diff --git a/docs/errors/DF8102.md b/docs/errors/DF8102.md new file mode 100644 index 0000000..8049b45 --- /dev/null +++ b/docs/errors/DF8102.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8102: Dock Not Registered + +## Message + +> Dock with id "`{id}`" is not registered. Use register() to add new docks. + +## Cause + +`ctx.docks.update(view)` was called with an `id` that has no prior registration. `update` is for mutating existing entries; new entries must go through `register`. + +## Fix + +- Use `ctx.docks.register(view)` for new entries. +- Verify the id matches a previously registered dock — typos / case mismatches are the usual cause. + +## Source + +- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts) — `DevframeDocksHost.update()` throws when `views.has(view.id) === false`. diff --git a/docs/errors/DF8200.md b/docs/errors/DF8200.md new file mode 100644 index 0000000..dc73c70 --- /dev/null +++ b/docs/errors/DF8200.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8200: Terminal Session Already Registered + +## Message + +> Terminal session with id "`{id}`" already registered + +## Cause + +`ctx.terminals.register(session)` or `ctx.terminals.startChildProcess(opts, terminal)` was called with an `id` that another session already owns. Terminal ids are unique per hub context. + +## Fix + +- Pick a different session id (namespace under your tool: `my-tool:build-server`, `my-tool:tests`, …). +- If you want to reuse an existing session's slot, `ctx.terminals.remove(session)` first. + +## Source + +- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.register()` and `startChildProcess()` throw when the id is already taken. diff --git a/docs/errors/DF8201.md b/docs/errors/DF8201.md new file mode 100644 index 0000000..d828f6a --- /dev/null +++ b/docs/errors/DF8201.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8201: Terminal Session Not Registered + +## Message + +> Terminal session with id "`{id}`" not registered + +## Cause + +`ctx.terminals.update(patch)` was called with an `id` for which no session has been registered. + +## Fix + +- Use `ctx.terminals.register(session)` to add new sessions. +- Verify the id matches an existing session — common cause is updating after `remove()`. + +## Source + +- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — `DevframeTerminalsHost.update()` throws when `sessions.has(patch.id) === false`. diff --git a/docs/errors/DF8400.md b/docs/errors/DF8400.md new file mode 100644 index 0000000..c6f7257 --- /dev/null +++ b/docs/errors/DF8400.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8400: Command Already Registered + +## Message + +> Command "`{id}`" is already registered + +## Cause + +`ctx.commands.register(command)` was called with an `id` that another command already owns. Command ids are unique per hub context. + +## Fix + +- Pick a different command id (namespace under your tool: `my-tool:reload`, `my-tool:open-settings`, …). +- To replace, call `ctx.commands.unregister(id)` first. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` throws when `commands.has(command.id)`. diff --git a/docs/errors/DF8401.md b/docs/errors/DF8401.md new file mode 100644 index 0000000..c6b9a09 --- /dev/null +++ b/docs/errors/DF8401.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF8401: Cannot Change Command Id + +## Message + +> Cannot change the id of a command. Use register() to add new commands. + +## Cause + +The `update` handle returned by `ctx.commands.register(cmd)` received a patch with an `id` key. Command ids are immutable post-registration. + +## Fix + +- Drop `id` from the patch object. +- To register a new command, call `ctx.commands.register(newCmd)`. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` returns a `update` callable that throws when `'id' in patch`. diff --git a/docs/errors/DF8402.md b/docs/errors/DF8402.md new file mode 100644 index 0000000..cc1e3e3 --- /dev/null +++ b/docs/errors/DF8402.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF8402: Command Not Registered + +## Message + +> Command "`{id}`" is not registered + +## Cause + +`ctx.commands.execute(id, …)` (or the `update()` handle returned by `register()`) was called with an `id` that has no registration. The command was either never registered or has already been `unregister()`'d. + +## Fix + +- Register the command first via `ctx.commands.register({ id, title, handler })`. +- Verify the id — typos and stale references are the usual cause. +- If invoking client-side, register the command on the client via `ctx.commands.register` in the dock client script. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.execute()` and the `update` handle throw when the id is missing. diff --git a/docs/errors/DF8403.md b/docs/errors/DF8403.md new file mode 100644 index 0000000..9310fd1 --- /dev/null +++ b/docs/errors/DF8403.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF8403: Duplicate Command Id + +## Message + +> Command id "`{id}`" is already used by another command or child command + +## Cause + +`ctx.commands.register(command)` or a command handle `update()` received a command tree where at least one id is already owned by another top-level command or child command. Command ids are globally unique per hub context, including child commands. + +## Fix + +- Namespace every command id under your tool, such as `my-tool:reload` or `my-tool:open-settings`. +- Give each child command its own id instead of reusing the parent id. +- Unregister the previous command before replacing it with a different command tree. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` and command handle `update()` throw when a command id is duplicated. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index 3049c7f..ade90e6 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -5,7 +5,7 @@ outline: deep # Agent-Native Devframe ::: warning Experimental -The agent-native surface (`agent` field on `defineRpcFunction`, `DevToolsAgentHost`, and the `devframe/adapters/mcp` adapter) is experimental and may change without a major version bump until it stabilizes. +The agent-native surface (`agent` field on `defineRpcFunction`, `DevframeAgentHost`, and the `devframe/adapters/mcp` adapter) is experimental and may change without a major version bump until it stabilizes. ::: Devframe can expose the same surface a browser UI consumes — RPC functions, resources, and shared state — to coding agents (Claude Desktop / Cursor / Zed / Claude Code, or any MCP-speaking client). Agent exposure is opt-in per function; functions stay private by default. @@ -15,7 +15,7 @@ Devframe can expose the same surface a browser UI consumes — RPC functions, re Three building blocks: 1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private. -2. **`ctx.agent`** — a host exposed on `DevToolsNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary). +2. **`ctx.agent`** — a host exposed on `DevframeNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary). 3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, currently over `stdio`. ## Exposing an RPC function diff --git a/docs/guide/built-with.md b/docs/guide/built-with.md index 7c8372b..45f7b8d 100644 --- a/docs/guide/built-with.md +++ b/docs/guide/built-with.md @@ -4,7 +4,7 @@ outline: deep # Built with Devframe -Real-world devtools shipping on Devframe: +Real-world devframes: - [**Vite DevTools**](https://devtools.vite.dev/) — the host that bundles multiple devframes into one UI (docks, command palette, terminals). Mount your own definition into it via the [`vite` adapter](/adapters/vite). - [**ESLint Config Inspector**](https://github.com/eslint/config-inspector) — official ESLint tool for inspecting flat configs. diff --git a/docs/guide/client.md b/docs/guide/client.md index 215db22..7d3a286 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -8,7 +8,7 @@ The browser-side client is how a dock iframe, remote-hosted page, or standalone ## Connecting -`devframe/client` exports `connectDevframe` (an alias of `getDevToolsRpcClient`) — use either name: +`devframe/client` exports `connectDevframe` (an alias of `getDevframeRpcClient`) — use either name: ```ts import { connectDevframe } from 'devframe/client' @@ -18,7 +18,7 @@ const rpc = await connectDevframe() const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) ``` -`connectDevframe` auto-detects the backend via `__devtools/__connection.json`, with a sequence of base URLs as fallback. No arguments are needed when the client is hosted from the default mount path. +`connectDevframe` auto-detects the backend via `__devframe/__connection.json`, with a sequence of base URLs as fallback. No arguments are needed when the client is hosted from the default mount path. ### Runtime basePath discovery @@ -46,7 +46,7 @@ await connectDevframe({ | Option | Description | |--------|-------------| -| `baseURL` | Mount path to probe for `__connection.json`. Accepts an array for fallback. Default: `'./'` — resolved relative to `document.baseURI` so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. `'/__devtools/'`) when calling from outside the SPA — say, an embedded webcomponent injected into a host app. | +| `baseURL` | Mount path to probe for `__connection.json`. Accepts an array for fallback. Default: `'./'` — resolved relative to `document.baseURI` so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. `'/__devframe/'`) when calling from outside the SPA — say, an embedded webcomponent injected into a host app. | | `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. | | `cacheOptions` | `true` to enable caching with defaults, or an options object. | | `wsOptions` | Forwarded to the WebSocket transport (reconnect, heartbeat, etc.). | @@ -55,7 +55,7 @@ await connectDevframe({ ## Modes -The client runs in one of two modes depending on the backend advertised in `__devtools/__connection.json`: +The client runs in one of two modes depending on the backend advertised in `__devframe/__connection.json`: | Backend | When | Capabilities | |---------|------|--------------| @@ -89,7 +89,7 @@ const ok = await rpc.requestTrustWithToken('another-token') ### Broadcast-channel sync -`connectDevframe` listens on a shared `BroadcastChannel` (named `vite-devtools-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with it automatically — no reload required. +`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with it automatically — no reload required. ## Calling functions diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 27cddc8..8a51903 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -68,19 +68,19 @@ The CLI dev server sets `mode: 'dev'`; `createBuild` sets `mode: 'build'`. ## The setup context -`setup(ctx)` receives a `DevToolsNodeContext`: +`setup(ctx)` receives a `DevframeNodeContext`: ```ts -interface DevToolsNodeContext { +interface DevframeNodeContext { readonly cwd: string readonly workspaceRoot: string readonly mode: 'dev' | 'build' - host: DevToolsHost // runtime abstraction (mountStatic / resolveOrigin / getStorageDir) + host: DevframeHost // runtime abstraction (mountStatic / resolveOrigin / getStorageDir) rpc: RpcFunctionsHost // register + broadcast + sharedState - views: DevToolsViewHost // static file hosting (`hostStatic`) - diagnostics: DevToolsDiagnosticsHost - agent: DevToolsAgentHost // experimental + views: DevframeViewHost // static file hosting (`hostStatic`) + diagnostics: DevframeDiagnosticsHost + agent: DevframeAgentHost // experimental } ``` diff --git a/docs/guide/diagnostics.md b/docs/guide/diagnostics.md index bdbed29..5775225 100644 --- a/docs/guide/diagnostics.md +++ b/docs/guide/diagnostics.md @@ -4,7 +4,7 @@ outline: deep # Structured Diagnostics -`ctx.diagnostics` is a thin layer over [`nostics`](https://www.npmjs.com/package/nostics) that lets integrations register coded errors and warnings into a shared lookup without depending on `nostics` directly. Use it for author-defined coded diagnostics — errors, warnings, deprecations — with a stable code, a documentation URL, and a structured payload. For free-form runtime output that should appear in the DevTools UI, use [`ctx.messages`](https://devtools.vite.dev/kit/messages). +`ctx.diagnostics` is a thin layer over [`nostics`](https://www.npmjs.com/package/nostics) that lets integrations register coded errors and warnings into a shared lookup without depending on `nostics` directly. Use it for author-defined coded diagnostics — errors, warnings, deprecations — with a stable code, a documentation URL, and a structured payload. For free-form runtime output that should appear in the Devframe UI, use [`ctx.messages`](https://devtools.vite.dev/kit/messages). | Surface | Purpose | Example | |---------|---------|---------| @@ -14,7 +14,7 @@ outline: deep ## Shape ```ts -interface DevToolsDiagnosticsHost { +interface DevframeDiagnosticsHost { /** Proxy-backed lookup over every registered code. */ readonly logger: Record @@ -135,6 +135,6 @@ Each page covers the message, cause, example, and fix — see any [DF code page] ## When to use what - **`ctx.diagnostics`** — coded conditions worth looking up: misconfiguration, deprecations, validation failures, internal invariants. Always docs-backed. Often thrown. -- **`ctx.messages`** — user-facing activity surfaces in the DevTools UI: progress indicators, audit results, "URL copied" toasts. Just a message and a level. +- **`ctx.messages`** — user-facing activity surfaces in the Devframe UI: progress indicators, audit results, "URL copied" toasts. Just a message and a level. -Diagnostics target tool authors and CI; messages target the human in front of the DevTools panel. +Diagnostics target tool authors and CI; messages target the human in front of the Devframe panel. diff --git a/docs/guide/hub.md b/docs/guide/hub.md new file mode 100644 index 0000000..12e315c --- /dev/null +++ b/docs/guide/hub.md @@ -0,0 +1,65 @@ +--- +outline: deep +--- + +# Hub (multi-tool) + +`@devframes/hub` extends devframe with the orchestration features that only make sense when many devtools share a UI: a dock registry, terminal aggregation, message/toast queue, and a command palette. It does not ship UI — each framework kit (e.g. `@vitejs/devtools-kit`) provides its own UI on top of the hub's RPC + shared-state protocol. + +> [!WARNING] Experimental +> The hub API surface is still being refined. Names may change before 1.0. + +## What the hub adds + +A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` with four subsystems: + +| Subsystem | Surface | Purpose | +|---|---|---| +| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render). | +| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. | +| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | +| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. | + +Plus a `createJsonRenderer(spec)` factory for building remote-UI panels via the framework-neutral json-render DSL. + +## Built-in RPC + +Every hub context auto-registers this RPC function so framework kits don't reimplement it: + +- `hub:commands:execute` — invoke a registered server command by id. `await rpc.call('hub:commands:execute', 'my-tool:do-thing', ...args)`. + +Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface. + +## Mounting a devframe into a hub + +`mountDevframe(ctx, def)` is the framework-neutral primitive that registers any `DevframeDefinition` as a dock and runs its `setup(ctx)`: + +```ts +import { createHubContext, mountDevframe } from '@devframes/hub/node' + +const ctx = await createHubContext({ cwd, host, mode: 'dev' }) +await mountDevframe(ctx, myDevframe) +``` + +Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `mountDevframe`. + +## The protocol — what the UI sees + +A hub-aware UI doesn't import any hub classes; it reads three shared-state keys and one RPC method: + +| Channel | Type | What it carries | +|---|---|---| +| `devframe:docks` shared state | `DevframeDockEntry[]` | The full dock list, including the hub's `~terminals` / `~messages` / `~settings` builtins. | +| `devframe:commands` shared state | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). | +| `devframe:user-settings` shared state | `DevframeDocksUserSettings` | Persisted per-workspace hub settings. | +| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. | + +Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. + +## Example + +See [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) for a ~120-line Vite plugin that wires the hub end to end with a vanilla DOM UI. Every framework's hub host follows the same shape: a thin layer that adapts the framework's dev server to the hub. + +## Diagnostics + +Hub-side diagnostic codes live in the `DF8xxx` range. See the [error reference](/errors/) for the full list. diff --git a/docs/guide/index.md b/docs/guide/index.md index 1c830e3..871eea0 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -104,7 +104,7 @@ Devframe has zero dependencies on Vite or any `@vitejs/*` package — the same d ## What's next -- [Devframe Definition](./devframe-definition) — understand `defineDevframe` and the `DevToolsNodeContext` +- [Devframe Definition](./devframe-definition) — understand `defineDevframe` and the `DevframeNodeContext` - [Adapters](/adapters/) — pick the right deployment target for your tool - [RPC](./rpc) — define type-safe server functions your client can call - [Agent-Native](./agent-native) — expose your devframe to Claude Desktop, Cursor, or any MCP client diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index 9b23684..a968017 100644 --- a/docs/guide/rpc.md +++ b/docs/guide/rpc.md @@ -31,7 +31,7 @@ export const getModules = defineRpcFunction({ returns: v.array(v.object({ id: v.string(), size: v.number() })), setup: ctx => ({ handler: async ({ limit }) => { - // `ctx` is the DevToolsNodeContext. + // `ctx` is the DevframeNodeContext. return loadModules().slice(0, limit) }, }), @@ -53,7 +53,7 @@ export default defineDevframe({ }) ``` -Place each function in its own file under `src/rpc/functions/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap` in a sibling `src/context.ts`. +Place each function in its own file under `src/rpc/functions/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap` in a sibling `src/context.ts`. ### Naming convention @@ -95,7 +95,7 @@ Prefer a single object argument (`args: [v.object({ ... })]`) over positional ar Two ways to wire a handler: -- **`setup(ctx)`** — receives the `DevToolsNodeContext` and returns `{ handler, dump? }`. Use this when you need the context (shared state, logs, `ctx.mode`, etc.). +- **`setup(ctx)`** — receives the `DevframeNodeContext` and returns `{ handler, dump? }`. Use this when you need the context (shared state, logs, `ctx.mode`, etc.). - **`handler(...)`** — shorthand when the handler is pure and doesn't touch the context. ```ts @@ -167,7 +167,7 @@ const modules = await ctx.rpc.invokeLocal('my-devframe:get-modules', { limit: 10 ## Client-side calls -From the browser, [`connectDevframe`](./client) (or `getDevToolsRpcClient`) returns a client for calling registered functions: +From the browser, [`connectDevframe`](./client) (or `getDevframeRpcClient`) returns a client for calling registered functions: ```ts import { connectDevframe } from 'devframe/client' @@ -181,7 +181,7 @@ Client-side registration (for server→client calls) goes through `rpc.client.re ## Type-safe client registry -Devframe exposes two augmentable interfaces — `DevToolsRpcServerFunctions` (client→server calls) and `DevToolsRpcClientFunctions` (server→client calls) — so each registered RPC name shows up on the typed client. Augment them once per devframe via `declare module 'devframe'`. +Devframe exposes two augmentable interfaces — `DevframeRpcServerFunctions` (client→server calls) and `DevframeRpcClientFunctions` (server→client calls) — so each registered RPC name shows up on the typed client. Augment them once per devframe via `declare module 'devframe'`. The recommended pattern collects every server-side definition into a const array and feeds it through `RpcDefinitionsToFunctions`: @@ -192,7 +192,7 @@ import { getFile, getModules } from './rpc' const serverFunctions = [getModules, getFile] as const declare module 'devframe' { - interface DevToolsRpcServerFunctions + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} } ``` @@ -213,13 +213,13 @@ For one-off augmentations, declare a single key with `RpcFunctionDefinitionToFun import type { RpcFunctionDefinitionToFunction } from 'devframe/rpc' declare module 'devframe' { - interface DevToolsRpcServerFunctions { + interface DevframeRpcServerFunctions { 'my-devframe:get-modules': RpcFunctionDefinitionToFunction } } ``` -For server→client calls invoked via `ctx.rpc.broadcast`, augment `DevToolsRpcClientFunctions` the same way. +For server→client calls invoked via `ctx.rpc.broadcast`, augment `DevframeRpcClientFunctions` the same way. ## Static dumps @@ -264,7 +264,7 @@ Devframe's WS transport ships payloads using one of two encoders, picked per RPC | `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | | `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | -The wire stays plain JSON when every participating function is JSON-flagged — debuggable in DevTools, friendly to MCP, and a good default for tools that already speak JSON. +The wire stays plain JSON when every participating function is JSON-flagged — debuggable in Devframe, friendly to MCP, and a good default for tools that already speak JSON. ### Discovering shape errors during dev diff --git a/docs/guide/shared-state.md b/docs/guide/shared-state.md index ed01a75..074d9f2 100644 --- a/docs/guide/shared-state.md +++ b/docs/guide/shared-state.md @@ -139,11 +139,11 @@ Protocol adapters (the [MCP adapter](./agent-native), for example) use this to s ## Type-safe keys -Augment `DevToolsRpcSharedStates` to type each shared-state key once, then both server and client lookups stay typed without per-call generics: +Augment `DevframeRpcSharedStates` to type each shared-state key once, then both server and client lookups stay typed without per-call generics: ```ts declare module 'devframe' { - interface DevToolsRpcSharedStates { + interface DevframeRpcSharedStates { 'my-devframe:state': { count: number items: { id: string, name: string }[] diff --git a/docs/helpers/nuxt.md b/docs/helpers/nuxt.md index fb484d8..e70da89 100644 --- a/docs/helpers/nuxt.md +++ b/docs/helpers/nuxt.md @@ -11,7 +11,7 @@ It handles the four things every Nuxt-powered standalone devtool needs: 1. **Base-agnostic assets.** Sets `app.baseURL: './'` and `vite.base: './'` so the same production build works at `/`, `/tool/`, and any other deployment path without build-time URL rewriting. 2. **Runtime RPC connection.** Adds a client plugin that calls [`connectDevframe()`](/guide/client) once on page load and provides the result as `$rpc` on the Nuxt app. 3. **Dev-time RPC bridge.** When you pass `devframe`, `nuxt dev` spins up a separate WebSocket RPC server and serves `__connection.json` so the SPA can reach it — no hand-rolled Vite plugin required. -4. **TypeScript augmentation.** `useNuxtApp().$rpc` is typed as `DevToolsRpcClient` out of the box. +4. **TypeScript augmentation.** `useNuxtApp().$rpc` is typed as `DevframeRpcClient` out of the box. ## Install diff --git a/docs/index.md b/docs/index.md index cf5494f..c5d9e19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ layout: home hero: name: Devframe - text: Framework-neutral foundation for DevTools + text: Framework-neutral foundation for Devframe tagline: One devframe definition, adapters to different environments. Managed communication layer, agent-native. image: src: /logo.svg diff --git a/examples/files-inspector/src/client/app.tsx b/examples/files-inspector/src/client/app.tsx index 59cfcd4..e2d8afa 100644 --- a/examples/files-inspector/src/client/app.tsx +++ b/examples/files-inspector/src/client/app.tsx @@ -1,4 +1,4 @@ -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' import { connectDevframe } from 'devframe/client' import { useEffect, useState } from 'preact/hooks' import { About } from './routes/about' @@ -19,7 +19,7 @@ function getRoute(basePath: string): string { export function App() { const basePath = getBasePath() const [route, setRoute] = useState(getRoute(basePath)) - const [rpc, setRpc] = useState(null) + const [rpc, setRpc] = useState(null) useEffect(() => { let cancelled = false diff --git a/examples/files-inspector/src/client/routes/about.tsx b/examples/files-inspector/src/client/routes/about.tsx index 1300666..f63d76f 100644 --- a/examples/files-inspector/src/client/routes/about.tsx +++ b/examples/files-inspector/src/client/routes/about.tsx @@ -1,7 +1,7 @@ -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' import { useEffect, useState } from 'preact/hooks' -export function About({ rpc, basePath }: { rpc: DevToolsRpcClient, basePath: string }) { +export function About({ rpc, basePath }: { rpc: DevframeRpcClient, basePath: string }) { const [cwd, setCwd] = useState('') useEffect(() => { diff --git a/examples/files-inspector/src/client/routes/home.tsx b/examples/files-inspector/src/client/routes/home.tsx index ca6dfd9..2f4fad1 100644 --- a/examples/files-inspector/src/client/routes/home.tsx +++ b/examples/files-inspector/src/client/routes/home.tsx @@ -1,7 +1,7 @@ -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' import { useEffect, useState } from 'preact/hooks' -export function Home({ rpc }: { rpc: DevToolsRpcClient }) { +export function Home({ rpc }: { rpc: DevframeRpcClient }) { const [files, setFiles] = useState([]) const [loading, setLoading] = useState(true) diff --git a/examples/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/rpc/index.ts index 181cb1b..e85d7d3 100644 --- a/examples/files-inspector/src/rpc/index.ts +++ b/examples/files-inspector/src/rpc/index.ts @@ -5,5 +5,5 @@ import { listFiles } from './functions/list-files.ts' export const serverFunctions = [getCwd, listFiles] as const declare module 'devframe' { - interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} } diff --git a/examples/files-inspector/tests/_utils.ts b/examples/files-inspector/tests/_utils.ts index 4e1f170..269a609 100644 --- a/examples/files-inspector/tests/_utils.ts +++ b/examples/files-inspector/tests/_utils.ts @@ -5,10 +5,10 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { - DEVTOOLS_CONNECTION_META_FILENAME, + DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { - createH3DevToolsHost, + createH3DevframeHost, createHostContext, startHttpAndWs, } from 'devframe/node' @@ -68,7 +68,7 @@ export async function startInspectorServer( const app = new H3() const origin = `http://${host}:${port}` - const h3Host = createH3DevToolsHost({ + const h3Host = createH3DevframeHost({ origin, appName: devframe.id, mount: (base, dir) => { @@ -79,7 +79,7 @@ export async function startInspectorServer( const ctx = await createHostContext({ cwd, mode: 'dev', host: h3Host }) await devframe.setup(ctx) - const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + const metaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, resolve(distDir)) diff --git a/examples/files-inspector/tests/static-build.test.ts b/examples/files-inspector/tests/static-build.test.ts index 4179485..97302ca 100644 --- a/examples/files-inspector/tests/static-build.test.ts +++ b/examples/files-inspector/tests/static-build.test.ts @@ -4,8 +4,8 @@ import os from 'node:os' import path from 'node:path' import { createBuild } from 'devframe/adapters/build' import { - DEVTOOLS_CONNECTION_META_FILENAME, - DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME, + DEVFRAME_CONNECTION_META_FILENAME, + DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from 'devframe/constants' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import devframe from '../src/devframe' @@ -53,24 +53,24 @@ describe('static build (CLI build surface)', () => { }) it('writes a static-backend connection meta next to index.html', async () => { - // The meta sits at the SPA root (not under `__devtools/`) so any + // The meta sits at the SPA root (not under `__devframe/`) so any // generic static file server (`serve`, `nginx`, `python -m http.server`) // can serve it as a flat tree without nested-dir exclusions. const meta = JSON.parse( await readFile( - path.join(outBuild, DEVTOOLS_CONNECTION_META_FILENAME), + path.join(outBuild, DEVFRAME_CONNECTION_META_FILENAME), 'utf-8', ), ) as { backend: string } expect(meta).toMatchObject({ backend: 'static' }) - // Guard the design: nothing should land under a `__devtools/` subdir. - expect(existsSync(path.join(outBuild, '__devtools'))).toBe(false) + // Guard the design: nothing should land under a `__devframe/` subdir. + expect(existsSync(path.join(outBuild, '__devframe'))).toBe(false) }) it('dumps both RPC functions into the manifest', async () => { const manifest = JSON.parse( await readFile( - path.join(outBuild, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME), + path.join(outBuild, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME), 'utf-8', ), ) as DumpManifest diff --git a/examples/files-inspector/tests/static-serve.test.ts b/examples/files-inspector/tests/static-serve.test.ts index 143fb83..c35da41 100644 --- a/examples/files-inspector/tests/static-serve.test.ts +++ b/examples/files-inspector/tests/static-serve.test.ts @@ -88,8 +88,8 @@ describe('static serve (deployed SPA contract)', () => { // This is the path the SPA fetches via relative `./__connection.json` // resolved against `document.baseURI`. The 404 the user originally // reported with `serve dist-static` was caused by the old layout - // putting this file under `/__devtools/__connection.json`, which a - // SPA at any non-`/__devtools/` mount could not discover. + // putting this file under `/__devframe/__connection.json`, which a + // SPA at any non-`/__devframe/` mount could not discover. const res = await fetch(`${server.origin}${mountBase}__connection.json`) expect(res.status).toBe(200) const meta = await res.json() as { backend: string } @@ -120,11 +120,11 @@ describe('static serve (deployed SPA contract)', () => { expect(record.output).toEqual(['README.md', 'package.json', 'sample.txt']) }) - it('does not expose a stray `__devtools/` directory at the SPA root', async () => { + it('does not expose a stray `__devframe/` directory at the SPA root', async () => { // Regression guard: the build output is intentionally flat — - // re-introducing a `__devtools/` subdir would create a nested + // re-introducing a `__devframe/` subdir would create a nested // path the relative-base discovery in the SPA cannot reach. - const res = await fetch(`${server.origin}${mountBase}__devtools/__connection.json`) + const res = await fetch(`${server.origin}${mountBase}__devframe/__connection.json`) expect(res.status).toBe(404) }) }) diff --git a/examples/minimal-next-devframe-hub/.gitignore b/examples/minimal-next-devframe-hub/.gitignore new file mode 100644 index 0000000..999e272 --- /dev/null +++ b/examples/minimal-next-devframe-hub/.gitignore @@ -0,0 +1,6 @@ +.next +dist +next-env.d.ts +node_modules +out +.turbo diff --git a/examples/minimal-next-devframe-hub/README.md b/examples/minimal-next-devframe-hub/README.md new file mode 100644 index 0000000..bd89a0e --- /dev/null +++ b/examples/minimal-next-devframe-hub/README.md @@ -0,0 +1,36 @@ +# Minimal Next Devframe Hub + +A protocol-witness example. The `src/client/devframe/minimal-next-devframe-hub.ts` file wires `@devframes/hub` into a Next.js App Router app by lazily starting a side-car RPC/WS server from a Node route handler. + +## Run it + +```sh +pnpm install +pnpm --filter minimal-next-devframe-hub dev +``` + +Open the printed URL. You should see: + +- A status line showing the RPC backend +- A **Docks** list with hub built-ins and the mounted demo devframe +- A **Commands** list populated from server-side registrations +- A **Messages** list populated via `messages.add()` on the server +- A **Terminals** list, empty unless a devframe registers one +- A button that exercises `hub:commands:execute` by dispatching the sample ping command + +## What the example proves + +- `createHubContext()` boots a hub without any Vite-specific code path +- A `DevframeHost` impl plugs Next host specifics into the hub uniformly +- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock +- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed +- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json` + +## Files + +| File | Role | +|---|---| +| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — creates hub context and side-car WS | +| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Connection-meta endpoint for `/__hub/__connection.json` that starts the singleton host | +| `src/client/devframe/demo-devframe.ts` | A sample `DevframeDefinition` that plugs into the host | +| `src/client/app/page.tsx` | The browser-side UI that consumes the hub protocol | diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json new file mode 100644 index 0000000..f2ef7e8 --- /dev/null +++ b/examples/minimal-next-devframe-hub/package.json @@ -0,0 +1,27 @@ +{ + "name": "minimal-next-devframe-hub", + "type": "module", + "version": "0.4.1", + "private": true, + "description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", + "scripts": { + "dev": "next dev src/client", + "build": "next build src/client", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@devframes/hub": "workspace:*", + "devframe": "workspace:*", + "next": "catalog:frontend", + "react": "catalog:frontend", + "react-dom": "catalog:frontend" + }, + "devDependencies": { + "@types/react": "catalog:types", + "@types/react-dom": "catalog:types", + "get-port-please": "catalog:deps", + "pathe": "catalog:deps", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts b/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts new file mode 100644 index 0000000..96724f7 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_hub/%5F_connection.json/route.ts @@ -0,0 +1,9 @@ +import { ensureMinimalNextDevframeHub } from '../../../devframe/minimal-next-devframe-hub' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const hub = await ensureMinimalNextDevframeHub() + return Response.json(hub.connectionMeta) +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/globals.css b/examples/minimal-next-devframe-hub/src/client/app/globals.css new file mode 100644 index 0000000..7bc5dff --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/globals.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; + padding: 1.5rem 2rem; +} + +main { + max-width: 800px; + margin-inline: auto; +} + +header h1 { + margin-bottom: 0.25rem; +} + +header p { + margin-top: 0; + opacity: 0.7; +} + +section { + margin-block: 1.5rem; +} + +h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.8; + border-bottom: 1px solid currentcolor; + padding-bottom: 0.25rem; +} + +ul { + list-style: none; + padding-left: 0; +} + +li { + padding: 0.5rem 0.75rem; + border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); + border-radius: 0.5rem; + margin-bottom: 0.5rem; + font-family: ui-monospace, monospace; + font-size: 0.9rem; +} + +li.muted { + opacity: 0.5; + font-style: italic; +} + +code { + font-family: ui-monospace, monospace; + background: color-mix(in srgb, currentcolor 10%, transparent); + padding: 0.1em 0.35em; + border-radius: 0.25em; +} + +button { + font: inherit; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: 1px solid currentcolor; + background: transparent; + cursor: pointer; +} + +button:hover { + background: color-mix(in srgb, currentcolor 10%, transparent); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +#status { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + opacity: 0.7; +} + +#status.error span { + color: #c33; +} + +#status.ready span { + color: #2a7; +} + +.badge { + margin-left: 0.35rem; +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/layout.tsx b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx new file mode 100644 index 0000000..5090a20 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import './globals.css' + +export const metadata: Metadata = { + title: 'Minimal Next Devframe Hub', + description: 'A Next.js host for the @devframes/hub protocol.', +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx new file mode 100644 index 0000000..65e3282 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -0,0 +1,200 @@ +'use client' + +import type { DevframeRpcClient } from '@devframes/hub/client' +import type { + DevframeCommandEntry, + DevframeDockEntry, + DevframeMessageEntry, + DevframeTerminalSession, +} from '@devframes/hub/types' +import type { ReactNode } from 'react' +import { connectDevframe } from '@devframes/hub/client' +import { useEffect, useRef, useState } from 'react' + +const HUB_BASE = '/__hub/' + +interface Status { + text: string + kind?: 'ready' | 'error' +} + +type TerminalSummary = Pick + +export default function Page() { + const [status, setStatus] = useState({ text: 'Connecting...' }) + const [docks, setDocks] = useState([]) + const [commands, setCommands] = useState([]) + const [messages, setMessages] = useState([]) + const [terminals, setTerminals] = useState([]) + const [pingResult, setPingResult] = useState('Run ping') + const rpcRef = useRef(null) + + useEffect(() => { + let cancelled = false + let cleanup: (() => void) | undefined + + async function run() { + try { + const rpc = await connectDevframe({ baseURL: HUB_BASE }) + if (cancelled) + return + + rpcRef.current = rpc + setStatus({ text: `Connected: backend=${rpc.connectionMeta.backend}`, kind: 'ready' }) + + const docksState = await rpc.sharedState.get( + 'devframe:docks', + { initialValue: [] }, + ) + const commandsState = await rpc.sharedState.get( + 'devframe:commands', + { initialValue: [] }, + ) + + const renderDocks = () => setDocks(docksState.value() ?? []) + const renderCommands = () => setCommands(commandsState.value() ?? []) + docksState.on('updated', renderDocks) + commandsState.on('updated', renderCommands) + renderDocks() + renderCommands() + + const refreshMessages = async () => { + const entries = await rpc.call( + 'minimal-next-devframe-hub:messages:list' as any, + ) as DevframeMessageEntry[] + if (!cancelled) + setMessages(entries) + } + + const refreshTerminals = async () => { + const sessions = await rpc.call( + 'minimal-next-devframe-hub:terminals:list' as any, + ) as TerminalSummary[] + if (!cancelled) + setTerminals(sessions) + } + + await refreshMessages() + await refreshTerminals() + + const interval = window.setInterval(() => { + void refreshMessages() + void refreshTerminals() + }, 2000) + + cleanup = () => window.clearInterval(interval) + } + catch (err) { + if (!cancelled) + setStatus({ text: `Failed: ${(err as Error).message}`, kind: 'error' }) + } + } + + void run() + + return () => { + cancelled = true + cleanup?.() + rpcRef.current = null + } + }, []) + + async function ping() { + if (!rpcRef.current) + return + try { + const result = await rpcRef.current.call( + 'hub:commands:execute' as any, + 'minimal-next-devframe-hub:ping', + ) + setPingResult(`Ping returned ${JSON.stringify(result)}`) + } + catch (err) { + setPingResult(`Error: ${(err as Error).message}`) + } + } + + return ( +

+
+

Minimal Next Devframe Hub

+

+ Protocol witness: verifies + {' '} + @devframes/hub + {' '} + end to end from a Next.js host. +

+
+ +
+ {status.text} +
+ + + {docks.map(dock => ( +
  • + {dock.title} + {' '} + {dock.id} + {'badge' in dock && dock.badge + ? {`[${dock.badge}]`} + : null} +
  • + ))} +
    + + + {commands.map(command => ( +
  • + {command.title} + {' '} + {command.id} +
  • + ))} +
    + +
    + +
    + + + {messages.map(message => ( +
  • + {`[${message.level}]`} + {' '} + {message.message} +
  • + ))} +
    + + + {terminals.map(terminal => ( +
  • + {terminal.title} + {' '} + {terminal.id} + {' '} + {terminal.status} +
  • + ))} +
    +
    + ) +} + +function Panel({ title, empty, children }: { + title: string + empty: string + children: ReactNode +}) { + const items = Array.isArray(children) ? children : [children] + return ( +
    +

    {title}

    + +
    + ) +} diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/demo-devframe.ts b/examples/minimal-next-devframe-hub/src/client/devframe/demo-devframe.ts new file mode 100644 index 0000000..470fe52 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/devframe/demo-devframe.ts @@ -0,0 +1,26 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { defineDevframe } from 'devframe/types' + +export default defineDevframe({ + id: 'next-demo-tool', + name: 'Next Demo Tool', + icon: 'ph:rocket-duotone', + basePath: '/__next-demo-tool/', + async setup(rawCtx) { + const ctx = rawCtx as unknown as DevframeHubContext + + ctx.commands.register({ + id: 'next-demo-tool:say-hello', + title: 'Next Demo Tool: Say Hello', + icon: 'ph:hand-waving-duotone', + category: 'demo', + handler: () => 'Hello from the Next demo command!', + }) + + await ctx.messages.add({ + level: 'info', + message: 'Next demo devframe loaded', + description: 'Registered via mountDevframe() from the Next host.', + }) + }, +}) diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts new file mode 100644 index 0000000..a34009a --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -0,0 +1,136 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import type { StartedServer } from 'devframe/node' +import type { ConnectionMeta, DevframeDefinition, DevframeHost } from 'devframe/types' +import { homedir } from 'node:os' +import process from 'node:process' +import { defineHubRpcFunction } from '@devframes/hub' +import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { startHttpAndWs } from 'devframe/node' +import { getPort } from 'get-port-please' +import { join } from 'pathe' +import demoDevframe from './demo-devframe' + +export interface MinimalNextDevframeHubOptions { + /** Preferred port for the side-car RPC/WS server. Default: a free port near 9877. */ + port?: number + /** Hostname for the side-car server. Default: `localhost`. */ + host?: string + /** Workspace root used by hub host capabilities. Default: `process.cwd()`. */ + cwd?: string + /** Devframes to mount as docks. */ + devframes?: DevframeDefinition[] +} + +export interface StartedMinimalNextDevframeHub extends StartedServer { + context: DevframeHubContext + connectionMeta: ConnectionMeta & { backend: 'websocket', websocket: number } +} + +const minimalNextHubMessagesList = defineHubRpcFunction({ + name: 'minimal-next-devframe-hub:messages:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.messages.entries.values()) + }, + }), +}) + +const minimalNextHubTerminalsList = defineHubRpcFunction({ + name: 'minimal-next-devframe-hub:terminals:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.terminals.sessions.values()).map(s => ({ + id: s.id, + title: s.title, + description: s.description, + status: s.status, + })) + }, + }), +}) + +export async function minimalNextDevframeHub( + options: MinimalNextDevframeHubOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd() + const hostName = options.host ?? 'localhost' + + const host: DevframeHost = { + mountStatic() { + // Static mounting for devframe SPAs would route through Next middleware + // in a fuller host. This minimal example keeps mounted devframes headless. + }, + resolveOrigin() { + return `http://${hostName}:3000` + }, + getStorageDir(scope) { + return scope === 'workspace' + ? join(cwd, 'node_modules/.minimal-next-devframe-hub') + : join(homedir(), '.minimal-next-devframe-hub') + }, + } + + const port = options.port ?? await getPort({ host: hostName, port: 9877, random: false }) + + const context = await createHubContext({ + cwd, + workspaceRoot: cwd, + mode: 'dev', + host, + builtinRpcDeclarations: [ + minimalNextHubMessagesList, + minimalNextHubTerminalsList, + ], + }) + + context.commands.register({ + id: 'minimal-next-devframe-hub:ping', + title: 'Next Hub: Ping', + icon: 'ph:bell-duotone', + category: 'hub', + handler: () => 'pong', + }) + + await context.messages.add({ + level: 'success', + message: 'Minimal Next Devframe Hub started', + description: `Side-car WS on port ${port}. ${options.devframes?.length ?? 1} devframe(s) registered.`, + }) + + for (const def of options.devframes ?? [demoDevframe]) { + await mountDevframe(context, def) + } + + const started = await startHttpAndWs({ + context, + host: hostName, + port, + auth: false, + }) + + return Object.assign(started, { + context, + connectionMeta: { + backend: 'websocket' as const, + websocket: started.port, + }, + }) +} + +const GLOBAL_KEY = '__minimalNextDevframeHub' + +type GlobalWithHub = typeof globalThis & { + [GLOBAL_KEY]?: Promise +} + +export function ensureMinimalNextDevframeHub( + options: MinimalNextDevframeHubOptions = {}, +): Promise { + const globalHub = globalThis as GlobalWithHub + globalHub[GLOBAL_KEY] ??= minimalNextDevframeHub(options) + return globalHub[GLOBAL_KEY] +} diff --git a/examples/minimal-next-devframe-hub/src/client/next.config.mjs b/examples/minimal-next-devframe-hub/src/client/next.config.mjs new file mode 100644 index 0000000..d551674 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { unoptimized: true }, + // The workspace typecheck owns source-level project references. + typescript: { ignoreBuildErrors: true }, +} + +export default nextConfig diff --git a/examples/minimal-next-devframe-hub/src/client/tsconfig.json b/examples/minimal-next-devframe-hub/src/client/tsconfig.json new file mode 100644 index 0000000..62aa7e6 --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + ".next", + "out" + ] +} diff --git a/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts b/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts new file mode 100644 index 0000000..7bd797a --- /dev/null +++ b/examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts @@ -0,0 +1,58 @@ +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { minimalNextDevframeHub } from '../src/client/devframe/minimal-next-devframe-hub' + +vi.stubGlobal('WebSocket', WebSocket) + +function bootRpc(port: number) { + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }) + return createRpcClient({}, { channel }) +} + +describe('minimal-next-devframe-hub (example)', () => { + let server: Awaited> | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + it('returns connection meta pointing at the WS backend', async () => { + server = await minimalNextDevframeHub({ host: '127.0.0.1' }) + + expect(server.connectionMeta).toEqual({ + backend: 'websocket', + websocket: server.port, + }) + }) + + it('registers hub built-in docks and the mounted demo devframe', async () => { + server = await minimalNextDevframeHub({ host: '127.0.0.1' }) + + const dockIds = server.context.docks.values().map(d => d.id) + expect(dockIds).toContain('next-demo-tool') + expect(dockIds).toContain('~terminals') + expect(dockIds).toContain('~messages') + expect(dockIds).toContain('~settings') + }) + + it('lists startup and demo messages through the kit-local RPC', async () => { + server = await minimalNextDevframeHub({ host: '127.0.0.1' }) + + const rpc = bootRpc(server.port) + const messages = await rpc.$call('minimal-next-devframe-hub:messages:list') as { message: string }[] + expect(messages.map(m => m.message)).toContain('Minimal Next Devframe Hub started') + expect(messages.map(m => m.message)).toContain('Next demo devframe loaded') + }) + + it('executes the ping command through the hub command RPC', async () => { + server = await minimalNextDevframeHub({ host: '127.0.0.1' }) + + const rpc = bootRpc(server.port) + await expect( + rpc.$call('hub:commands:execute', 'minimal-next-devframe-hub:ping'), + ).resolves.toBe('pong') + }) +}) diff --git a/examples/minimal-next-devframe-hub/tsconfig.json b/examples/minimal-next-devframe-hub/tsconfig.json new file mode 100644 index 0000000..77efbe1 --- /dev/null +++ b/examples/minimal-next-devframe-hub/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": true, + "jsx": "preserve", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false, + "plugins": [{ "name": "next" }] + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ], + "exclude": [ + "dist", + ".next", + "out" + ] +} diff --git a/examples/minimal-next-devframe-hub/vitest.config.ts b/examples/minimal-next-devframe-hub/vitest.config.ts new file mode 100644 index 0000000..7fd3b94 --- /dev/null +++ b/examples/minimal-next-devframe-hub/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import { alias } from '../../alias' + +export default defineConfig({ + resolve: { + alias, + }, + test: { + environment: 'node', + }, +}) diff --git a/examples/minimal-vite-devframe-hub/README.md b/examples/minimal-vite-devframe-hub/README.md new file mode 100644 index 0000000..6159215 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/README.md @@ -0,0 +1,36 @@ +# Minimal Vite Devframe Hub + +A protocol-witness example. The `src/minimal-vite-devframe-hub.ts` file is the entire Vite host — about 120 lines of Vite plugin code that wires `@devframes/hub` into a Vite dev server. Every framework's Devframe Hub host follows the same shape. + +## Run it + +```sh +pnpm install +pnpm --filter minimal-vite-devframe-hub dev +``` + +Open the printed URL. You should see: + +- A status line showing the RPC backend +- A **Docks** list — one entry per `mountDevframe` call, plus the hub's built-in `~terminals` / `~messages` / `~settings` panels +- A **Commands** list — one entry per `commands.register()` +- A **Messages** list — populated via `messages.add()` on the server +- A **Terminals** list — empty unless a devframe registers one +- A button that exercises `hub:commands:execute` by dispatching the sample ping command + +## What the example proves + +- `createHubContext()` boots a hub without any Vite-specific code path +- A `DevframeHost` impl plugs framework specifics (storage paths, origin resolution) into the hub uniformly +- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock +- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed +- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware + +## Files + +| File | Role | +|---|---| +| `src/minimal-vite-devframe-hub.ts` | The Vite plugin — creates hub context, mounts middleware, side-car WS | +| `src/devframe.ts` | A sample `DevframeDefinition` that plugs into the kit | +| `src/client/main.ts` | The browser-side UI that consumes the hub protocol | +| `index.html` | The UI shell | diff --git a/examples/minimal-vite-devframe-hub/index.html b/examples/minimal-vite-devframe-hub/index.html new file mode 100644 index 0000000..daf3fb5 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/index.html @@ -0,0 +1,44 @@ + + + + + + Minimal Vite Devframe Hub + + + +
    +

    Minimal Vite Devframe Hub

    +

    Protocol witness — verifies @devframes/hub end to end.

    +
    +
    +
    + Connecting… +
    + +
    +

    Docks

    +
    • Waiting for snapshot…
    +
    + +
    +

    Commands

    +
    • Waiting for snapshot…
    +

    + +

    +
    + +
    +

    Messages

    +
    • No messages yet.
    +
    + +
    +

    Terminals

    +
    • No terminal sessions.
    +
    +
    + + + diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json new file mode 100644 index 0000000..7afa00a --- /dev/null +++ b/examples/minimal-vite-devframe-hub/package.json @@ -0,0 +1,20 @@ +{ + "name": "minimal-vite-devframe-hub", + "type": "module", + "version": "0.4.1", + "private": true, + "description": "Protocol-witness example — a tiny Vite Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@devframes/hub": "workspace:*", + "devframe": "workspace:*" + }, + "devDependencies": { + "get-port-please": "catalog:deps", + "pathe": "catalog:deps", + "vite": "catalog:build" + } +} diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts new file mode 100644 index 0000000..c98db05 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -0,0 +1,106 @@ +import type { + DevframeCommandEntry, + DevframeDockEntry, + DevframeMessageEntry, + DevframeTerminalSession, +} from '@devframes/hub/types' +import { connectDevframe } from '@devframes/hub/client' + +const HUB_BASE = '/__hub/' + +const statusEl = document.querySelector('#status')! +const connEl = document.querySelector('#conn')! +const docksEl = document.querySelector('#docks')! +const commandsEl = document.querySelector('#commands')! +const messagesEl = document.querySelector('#messages')! +const terminalsEl = document.querySelector('#terminals')! +const pingBtn = document.querySelector('#ping')! + +function setStatus(text: string, klass?: 'ready' | 'error') { + connEl.textContent = text + statusEl.className = klass ?? '' +} + +function renderList(host: HTMLElement, items: T[], render: (item: T) => string) { + if (!items.length) { + host.innerHTML = '
  • empty
  • ' + return + } + host.innerHTML = items.map(render).join('') +} + +async function main() { + setStatus('Connecting…') + + const rpc = await connectDevframe({ baseURL: HUB_BASE }) + setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready') + + // 1. Docks — read from `devframe:docks` shared state. + const docks = await rpc.sharedState.get( + 'devframe:docks', + { initialValue: [] }, + ) + const renderDocks = () => renderList(docksEl, docks.value() ?? [], (d) => { + const badge = d.badge ? ` [${d.badge}]` : '' + return `
  • ${d.title} ${d.id}${badge}
  • ` + }) + docks.on('updated', renderDocks) + renderDocks() + + // 2. Commands — read from `devframe:commands` shared state. + const commands = await rpc.sharedState.get( + 'devframe:commands', + { initialValue: [] }, + ) + const renderCommands = () => renderList(commandsEl, commands.value() ?? [], c => + `
  • ${c.title} ${c.id}
  • `) + commands.on('updated', renderCommands) + renderCommands() + + // 3. Messages — pulled via a kit-local RPC. A fuller kit would also + // register a client-side RPC handler for `devframe:messages:updated` + // to refresh on broadcast; this minimal example polls instead. + const refreshMessages = async () => { + const entries = await rpc.call( + 'minimal-vite-devframe-hub:messages:list' as any, + ) as DevframeMessageEntry[] + renderList(messagesEl, entries, m => + `
  • [${m.level}] ${m.message}
  • `) + } + await refreshMessages() + + // 4. Terminals — same pattern as messages. + const refreshTerminals = async () => { + const sessions = await rpc.call( + 'minimal-vite-devframe-hub:terminals:list' as any, + ) as Pick[] + renderList(terminalsEl, sessions, t => + `
  • ${t.title} ${t.id} · ${t.status}
  • `) + } + await refreshTerminals() + + setInterval(() => { + void refreshMessages() + void refreshTerminals() + }, 2000) + + // 5. Exercise the hub:commands:execute built-in by dispatching the + // sample ping command registered server-side. + pingBtn.addEventListener('click', async () => { + try { + const result = await rpc.call( + 'hub:commands:execute' as any, + 'minimal-vite-devframe-hub:ping', + ) + pingBtn.textContent = `Ping returned ${JSON.stringify(result)}` + } + catch (err) { + pingBtn.textContent = `Error: ${(err as Error).message}` + } + }) +} + +main().catch((err) => { + setStatus(`Failed: ${(err as Error).message}`, 'error') + console.error(err) +}) diff --git a/examples/minimal-vite-devframe-hub/src/client/style.css b/examples/minimal-vite-devframe-hub/src/client/style.css new file mode 100644 index 0000000..6127b5e --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/client/style.css @@ -0,0 +1,87 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; + padding: 1.5rem 2rem; + max-width: 800px; + margin-inline: auto; +} + +header h1 { + margin-bottom: 0.25rem; +} + +header p { + margin-top: 0; + opacity: 0.7; +} + +section { + margin-block: 1.5rem; +} + +h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.8; + border-bottom: 1px solid currentcolor; + padding-bottom: 0.25rem; +} + +ul { + list-style: none; + padding-left: 0; +} + +li { + padding: 0.5rem 0.75rem; + border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); + border-radius: 0.5rem; + margin-bottom: 0.5rem; + font-family: ui-monospace, monospace; + font-size: 0.9rem; +} + +li.muted { + opacity: 0.5; + font-style: italic; +} + +code { + font-family: ui-monospace, monospace; + background: color-mix(in srgb, currentcolor 10%, transparent); + padding: 0.1em 0.35em; + border-radius: 0.25em; +} + +button { + font: inherit; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: 1px solid currentcolor; + background: transparent; + cursor: pointer; +} + +button:hover { + background: color-mix(in srgb, currentcolor 10%, transparent); +} + +#status { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + opacity: 0.7; +} + +#status.error #conn { + color: #c33; +} + +#status.ready #conn { + color: #2a7; +} diff --git a/examples/minimal-vite-devframe-hub/src/devframe.ts b/examples/minimal-vite-devframe-hub/src/devframe.ts new file mode 100644 index 0000000..b8062b3 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/devframe.ts @@ -0,0 +1,35 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { defineDevframe } from 'devframe/types' + +/** + * A tiny demo devframe — proves a portable devframe can plug into the + * hub via {@link mountDevframe} and register its own docks / commands / + * messages on top of the host-provided subsystems. + * + * The `ctx` cast is the same one `@vitejs/devtools-kit`'s + * `createPluginFromDevframe` does today; the kit-level mount primitive + * threads a hub-augmented context through `d.setup`. + */ +export default defineDevframe({ + id: 'demo-tool', + name: 'Demo Tool', + icon: 'ph:rocket-duotone', + basePath: '/__demo-tool/', + async setup(rawCtx) { + const ctx = rawCtx as unknown as DevframeHubContext + + ctx.commands.register({ + id: 'demo-tool:say-hello', + title: 'Demo · Say Hello', + icon: 'ph:hand-waving-duotone', + category: 'demo', + handler: () => 'Hello from the demo command!', + }) + + await ctx.messages.add({ + level: 'info', + message: 'Demo devframe loaded', + description: 'Registered via mountDevframe(). Proves the devframe ↔ hub plug-in path works.', + }) + }, +}) diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts new file mode 100644 index 0000000..46a79d9 --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -0,0 +1,164 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import type { DevframeDefinition, DevframeHost } from 'devframe/types' +import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' +import { homedir } from 'node:os' +import { defineHubRpcFunction } from '@devframes/hub' +import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { startHttpAndWs } from 'devframe/node' +import { getPort } from 'get-port-please' +import { join } from 'pathe' + +export interface MinimalViteDevframeHubOptions { + /** Mount path for the hub's connection-meta endpoint. Default: `/__hub/`. */ + base?: string + /** Preferred port for the side-car RPC/WS server. Default: a free port near 9777. */ + port?: number + /** Devframes to mount as docks. */ + devframes?: DevframeDefinition[] +} + +// Minimal hub-local RPCs — used by the UI for read-side data. A more +// ambitious hub host might hoist these into `@devframes/hub` itself. +const minimalViteHubMessagesList = defineHubRpcFunction({ + name: 'minimal-vite-devframe-hub:messages:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.messages.entries.values()) + }, + }), +}) + +const minimalViteHubTerminalsList = defineHubRpcFunction({ + name: 'minimal-vite-devframe-hub:terminals:list', + type: 'static', + jsonSerializable: true, + setup: (ctx: DevframeHubContext) => ({ + async handler() { + return Array.from(ctx.terminals.sessions.values()).map(s => ({ + id: s.id, + title: s.title, + description: s.description, + status: s.status, + })) + }, + }), +}) + +/** + * A deliberately tiny Vite plugin that wires `@devframes/hub` into a Vite + * dev server: creates a hub context, implements the framework-neutral + * `DevframeHost` surface, and exposes the side-car WS endpoint to the + * browser via Vite middleware at `__connection.json`. + * + * This file is the entire Vite host — every other framework's hub host is + * the same shape: a thin layer that adapts a framework's dev server to the hub. + */ +export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = {}): Plugin { + const base = normalizeBase(options.base ?? '/__hub/') + let viteConfig: ResolvedConfig | undefined + let started: { close: () => Promise } | undefined + + return { + name: 'minimal-vite-devframe-hub', + apply: 'serve', + + configResolved(config) { + viteConfig = config + }, + + async configureServer(server: ViteDevServer) { + // Vite re-invokes `configureServer` on each restart. Tear down the + // previous server so we don't leak the WS port. + await started?.close().catch(() => {}) + started = undefined + + const cwd = viteConfig!.root + + const host: DevframeHost = { + mountStatic() { + // Static mounting for devframe SPAs would route through Vite's + // middleware in a fuller kit. This minimal example doesn't + // host any per-devframe SPA, so the no-op is honest. + }, + resolveOrigin() { + const resolved = server.resolvedUrls?.local?.[0] + return resolved ? new URL(resolved).origin : 'http://localhost:5173' + }, + getStorageDir(scope) { + return scope === 'workspace' + ? join(cwd, 'node_modules/.minimal-vite-devframe-hub') + : join(homedir(), '.minimal-vite-devframe-hub') + }, + } + + const port = options.port ?? await getPort({ port: 9777, random: false }) + + const context = await createHubContext({ + cwd, + workspaceRoot: cwd, + mode: 'dev', + host, + builtinRpcDeclarations: [ + // The minimal hub ships its own `messages:list` and `terminals:list` + // RPCs so the UI has something to read. A full hub kit would + // likely standardise these (alongside the built-in + // `hub:commands:execute`) but for the demo we keep them kit-local. + minimalViteHubMessagesList, + minimalViteHubTerminalsList, + ], + }) + + // Seed a sample command directly on the hub so the UI + // shows something even without any plugged-in devframes. + context.commands.register({ + id: 'minimal-vite-devframe-hub:ping', + title: 'Vite Hub · Ping', + icon: 'ph:bell-duotone', + category: 'kit', + handler: () => 'pong', + }) + await context.messages.add({ + level: 'success', + message: 'Minimal Vite Devframe Hub started', + description: `Side-car WS on port ${port}. ${options.devframes?.length ?? 0} devframe(s) registered.`, + }) + + for (const def of options.devframes ?? []) { + await mountDevframe(context, def) + } + + started = await startHttpAndWs({ + context, + port, + auth: false, + }) + + // Tell the browser where to find the WS endpoint. `connectDevframe` + // resolves this URL relative to its `baseURL` option. + const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` + server.middlewares.use(metaPath, (_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + }) + + server.httpServer?.once('close', () => { + void started?.close().catch(() => {}) + }) + }, + + async closeBundle() { + await started?.close().catch(() => {}) + started = undefined + }, + } +} + +function normalizeBase(base: string): string { + let out = base.startsWith('/') ? base : `/${base}` + if (!out.endsWith('/')) + out = `${out}/` + return out +} diff --git a/examples/minimal-vite-devframe-hub/tsconfig.json b/examples/minimal-vite-devframe-hub/tsconfig.json new file mode 100644 index 0000000..46dbffd --- /dev/null +++ b/examples/minimal-vite-devframe-hub/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts new file mode 100644 index 0000000..925e42b --- /dev/null +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import { alias } from '../../alias' +import demoDevframe from './src/devframe' +import { minimalViteDevframeHub } from './src/minimal-vite-devframe-hub' + +export default defineConfig({ + resolve: { alias }, + plugins: [ + minimalViteDevframeHub({ + devframes: [demoDevframe], + }), + ], +}) diff --git a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx index e1c6599..87a33af 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx @@ -1,12 +1,12 @@ 'use client' -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' import type { ReactNode } from 'react' import { connectDevframe } from 'devframe/client' import { createContext, useContext, useEffect, useState } from 'react' interface ConnectionState { - rpc: DevToolsRpcClient | null + rpc: DevframeRpcClient | null error: string | null } diff --git a/examples/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/rpc/index.ts index 66f044b..fbfb88e 100644 --- a/examples/next-runtime-snapshot/src/rpc/index.ts +++ b/examples/next-runtime-snapshot/src/rpc/index.ts @@ -6,5 +6,5 @@ import { system } from './functions/system.ts' export const serverFunctions = [system, memory, env] as const declare module 'devframe' { - interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} } diff --git a/examples/next-runtime-snapshot/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts index adcac27..4dcf3d5 100644 --- a/examples/next-runtime-snapshot/tests/_utils.ts +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -1,9 +1,9 @@ import type { StartedServer } from 'devframe/node' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { DEVTOOLS_CONNECTION_META_FILENAME } from 'devframe/constants' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { - createH3DevToolsHost, + createH3DevframeHost, createHostContext, startHttpAndWs, } from 'devframe/node' @@ -36,7 +36,7 @@ export async function startSnapshotServer(): Promise { const app = new H3() const origin = `http://${host}:${port}` - const h3Host = createH3DevToolsHost({ + const h3Host = createH3DevframeHost({ origin, appName: devframe.id, mount: (base, dir) => mountStaticHandler(app, base, dir), @@ -45,7 +45,7 @@ export async function startSnapshotServer(): Promise { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) await devframe.setup(ctx) - const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + const metaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) // Mount the static handler unconditionally — it only stat()s on // request, so a missing dist just produces 404s for HTML routes. diff --git a/examples/streaming-chat/README.md b/examples/streaming-chat/README.md index f0d2cd1..c3f9214 100644 --- a/examples/streaming-chat/README.md +++ b/examples/streaming-chat/README.md @@ -2,7 +2,7 @@ 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): +[vitejs/devframe#306](https://github.com/vitejs/devframe/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 diff --git a/examples/streaming-chat/src/client/app.tsx b/examples/streaming-chat/src/client/app.tsx index b7bcdf6..2ae1a40 100644 --- a/examples/streaming-chat/src/client/app.tsx +++ b/examples/streaming-chat/src/client/app.tsx @@ -1,4 +1,4 @@ -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' import type { StreamReader } from 'devframe/utils/streaming-channel' import type { ChatHistory, ChatMessage } from '../types' import { connectDevframe } from 'devframe/client' @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' import { CHANNEL_NAME, HISTORY_KEY } from '../constants' export function App() { - const [rpc, setRpc] = useState(null) + const [rpc, setRpc] = useState(null) const [demoPrompts, setDemoPrompts] = useState([]) const [messages, setMessages] = useState([]) const [liveTokens, setLiveTokens] = useState>({}) diff --git a/examples/streaming-chat/src/context.ts b/examples/streaming-chat/src/context.ts index 8b1c0eb..bd17c0b 100644 --- a/examples/streaming-chat/src/context.ts +++ b/examples/streaming-chat/src/context.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext, RpcStreamingChannel } from 'devframe/types' +import type { DevframeNodeContext, RpcStreamingChannel } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { ChatHistory } from './types.ts' @@ -8,13 +8,13 @@ export interface StreamingChatContext { pruneIfTooLarge: () => void } -const map = new WeakMap() +const map = new WeakMap() -export function setStreamingChatContext(ctx: DevToolsNodeContext, value: StreamingChatContext): void { +export function setStreamingChatContext(ctx: DevframeNodeContext, value: StreamingChatContext): void { map.set(ctx, value) } -export function getStreamingChatContext(ctx: DevToolsNodeContext): StreamingChatContext { +export function getStreamingChatContext(ctx: DevframeNodeContext): StreamingChatContext { const value = map.get(ctx) if (!value) throw new Error('streaming-chat context not initialised — call setStreamingChatContext in devframe.setup') diff --git a/examples/streaming-chat/src/rpc/index.ts b/examples/streaming-chat/src/rpc/index.ts index 4e800c1..be2ad84 100644 --- a/examples/streaming-chat/src/rpc/index.ts +++ b/examples/streaming-chat/src/rpc/index.ts @@ -6,5 +6,5 @@ import { send } from './functions/send.ts' export const serverFunctions = [demoPrompts, send, clear] as const declare module 'devframe' { - interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} } diff --git a/examples/streaming-chat/src/types.ts b/examples/streaming-chat/src/types.ts index 961f9a7..52795ed 100644 --- a/examples/streaming-chat/src/types.ts +++ b/examples/streaming-chat/src/types.ts @@ -16,7 +16,7 @@ export interface ChatHistory { } declare module 'devframe/types' { - interface DevToolsRpcSharedStates { + interface DevframeRpcSharedStates { [HISTORY_KEY]: ChatHistory } } diff --git a/examples/streaming-chat/tests/_utils.ts b/examples/streaming-chat/tests/_utils.ts index 54d48d9..755de93 100644 --- a/examples/streaming-chat/tests/_utils.ts +++ b/examples/streaming-chat/tests/_utils.ts @@ -1,13 +1,13 @@ -import type { DevToolsNodeContext, StartedServer } from 'devframe/node' +import type { DevframeNodeContext, 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, + DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { - createH3DevToolsHost, + createH3DevframeHost, createHostContext, startHttpAndWs, } from 'devframe/node' @@ -29,7 +29,7 @@ export const CLIENT_DIST = resolve(HERE, '../dist/client') */ 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 @@ -41,7 +41,7 @@ export async function startStreamingChatServer(): Promise mountStaticHandler(app, base, dir), @@ -50,7 +50,7 @@ export async function startStreamingChatServer(): Promise ({ backend: 'websocket', websocket: port })) if (existsSync(path.join(resolve(distDir), 'index.html'))) { mountStaticHandler(app, basePath, resolve(distDir)) diff --git a/examples/streaming-chat/tests/streaming-chat.test.ts b/examples/streaming-chat/tests/streaming-chat.test.ts index bc1aba4..a53ed8e 100644 --- a/examples/streaming-chat/tests/streaming-chat.test.ts +++ b/examples/streaming-chat/tests/streaming-chat.test.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext, StartedServer } from 'devframe/node' +import type { DevframeNodeContext, StartedServer } from 'devframe/node' import type { ChatHistory } from '../src/devframe' import { createRpcStreamingClientHost } from 'devframe/client' import { createRpcClient } from 'devframe/rpc/client' @@ -78,13 +78,13 @@ async function readAll(reader: AsyncIterable): Promise { return out } -async function getHistory(ctx: DevToolsNodeContext): Promise { +async function getHistory(ctx: DevframeNodeContext): 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, ctx: DevToolsNodeContext } + let server: StartedServer & { basePath: string, ctx: DevframeNodeContext } beforeEach(async () => { server = await startStreamingChatServer() diff --git a/package.json b/package.json index b28803d..39f5205 100644 --- a/package.json +++ b/package.json @@ -29,22 +29,22 @@ "postinstall": "npx simple-git-hooks && skills-npm" }, "devDependencies": { - "@antfu/eslint-config": "catalog:devtools", + "@antfu/eslint-config": "catalog:tooling", "@antfu/ni": "catalog:build", "@antfu/utils": "catalog:inlined", "@playwright/test": "catalog:testing", "@types/node": "catalog:types", "@types/ws": "catalog:types", - "bumpp": "catalog:devtools", - "eslint": "catalog:devtools", - "nano-staged": "catalog:devtools", - "simple-git-hooks": "catalog:devtools", - "skills-npm": "catalog:devtools", + "bumpp": "catalog:tooling", + "eslint": "catalog:tooling", + "nano-staged": "catalog:tooling", + "simple-git-hooks": "catalog:tooling", + "skills-npm": "catalog:tooling", "tsdown": "catalog:build", "tsnapi": "catalog:testing", "tsx": "catalog:build", "turbo": "catalog:build", - "typescript": "catalog:devtools", + "typescript": "catalog:tooling", "vite": "catalog:build", "vitest": "catalog:testing" }, diff --git a/packages/devframe/README.md b/packages/devframe/README.md index 6665550..6243ce3 100644 --- a/packages/devframe/README.md +++ b/packages/devframe/README.md @@ -6,7 +6,7 @@ [![JSDocs][jsdocs-src]][jsdocs-href] [![License][license-src]][license-href] -Framework-neutral foundation for building generic DevTools. +Framework-neutral foundation for building generic devframes. Documentation: [https://devfra.me/](https://devfra.me/). diff --git a/packages/devframe/package.json b/packages/devframe/package.json index ae80384..c8cbbbb 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -2,7 +2,7 @@ "name": "devframe", "type": "module", "version": "0.4.1", - "description": "Framework for building generic DevTools", + "description": "Framework for building generic devframes", "author": "Anthony Fu ", "license": "MIT", "homepage": "https://github.com/devframes/devframe#readme", @@ -30,7 +30,7 @@ "./helpers/vite": "./dist/helpers/vite.mjs", "./node": "./dist/node/index.mjs", "./node/auth": "./dist/node/auth.mjs", - "./node/internal": "./dist/node/internal.mjs", + "./node/hub-internals": "./dist/node/hub-internals.mjs", "./recipes/open-helpers": "./dist/recipes/open-helpers.mjs", "./rpc": "./dist/rpc/index.mjs", "./rpc/client": "./dist/rpc/client.mjs", diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index a82d104..3ea701e 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -7,12 +7,12 @@ import { colors as c } from 'devframe/utils/colors' import { structuredCloneStringify } from 'devframe/utils/structured-clone' import { dirname, resolve } from 'pathe' import { - DEVTOOLS_CONNECTION_META_FILENAME, - DEVTOOLS_RPC_DUMP_DIRNAME, - DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME, + DEVFRAME_CONNECTION_META_FILENAME, + DEVFRAME_RPC_DUMP_DIRNAME, + DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from '../constants' import { createHostContext } from '../node/context' -import { createH3DevToolsHost } from '../node/host-h3' +import { createH3DevframeHost } from '../node/host-h3' import { collectStaticRpcDump } from '../rpc/dump/static' import { strictJsonStringify } from '../rpc/serialization' import { resolveBasePath } from './_shared' @@ -47,7 +47,7 @@ export interface CreateBuildOptions { * - When `def.spa` is configured, also write `/spa-loader.json` * describing the SPA's data-loader mode (`'query'` / `'upload'` / * `'none'`). The output is mount-path agnostic — the same bundle - * works at `/`, `/devtools/`, or any base, no rewriting required. + * works at `/`, `/devframe/`, or any base, no rewriting required. */ export async function createBuild(d: DevframeDefinition, options: CreateBuildOptions = {}): Promise { const outDir = resolve(options.outDir ?? 'dist-static') @@ -66,11 +66,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt const ctx = await createHostContext({ cwd: process.cwd(), mode: 'build', - host: createH3DevToolsHost({ origin: 'http://localhost', appName: d.id }), + host: createH3DevframeHost({ origin: 'http://localhost', appName: d.id }), }) await d.setup(ctx) - await fs.mkdir(resolve(outDir, DEVTOOLS_RPC_DUMP_DIRNAME), { recursive: true }) + await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true }) const jsonSerializableMethods: string[] = [] for (const def of ctx.rpc.definitions.values()) { @@ -78,12 +78,12 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt jsonSerializableMethods.push(def.name) } await fs.writeFile( - resolve(outDir, DEVTOOLS_CONNECTION_META_FILENAME), + resolve(outDir, DEVFRAME_CONNECTION_META_FILENAME), JSON.stringify({ backend: 'static', jsonSerializableMethods }, null, 2), 'utf-8', ) - console.log(c.cyan`[devframe] writing RPC dump to ${resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME)}`) + console.log(c.cyan`[devframe] writing RPC dump to ${resolve(outDir, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME)}`) const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) const indent = options.pretty ? 2 : undefined for (const [filepath, file] of Object.entries(dump.files)) { @@ -102,7 +102,7 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt ) } await fs.writeFile( - resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME), + resolve(outDir, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME), JSON.stringify(dump.manifest, null, 2), 'utf-8', ) diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index fc417d5..1c0ec53 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -6,9 +6,9 @@ import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' -import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' import { createHostContext } from '../node/context' -import { createH3DevToolsHost } from '../node/host-h3' +import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' import { normalizeBasePath, resolveBasePath } from './_shared' @@ -125,7 +125,7 @@ export async function createDevServer( const app = options.app ?? new H3() const origin = `http://${host}:${port}` - const h3Host = createH3DevToolsHost({ + const h3Host = createH3DevframeHost({ origin, appName: def.id, mount: (base, dir) => { @@ -146,7 +146,7 @@ export async function createDevServer( // to know it's a websocket backend bound to that same port. The path // sits at the SPA root (next to index.html) so the deployed SPA can // discover it via a relative `./__connection.json` fetch. - const connectionMetaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + const connectionMetaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: port })) if (distDir) diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index 17d86b6..ae36dc8 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -1,9 +1,9 @@ -import type { DevToolsNodeContext } from '../types/context' +import type { DevframeNodeContext } from '../types/context' import type { DevframeDefinition } from '../types/devframe' export interface CreateEmbeddedOptions { /** Target context the devframe is registered into. Required. */ - ctx: DevToolsNodeContext + ctx: DevframeNodeContext } /** diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index f443e4d..ecf705a 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -1,11 +1,11 @@ -import type { DevToolsHost } from '../../../types/host' +import type { DevframeHost } from '../../../types/host' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { createHostContext } from 'devframe/node' import { describe, expect, it } from 'vitest' import { buildMcpServerFromContext } from '../build-server' -function nullHost(): DevToolsHost { +function nullHost(): DevframeHost { return { mountStatic: () => { /* no-op */ }, resolveOrigin: () => 'mcp://test', diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 365de52..e969d50 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -1,5 +1,5 @@ import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' -import type { AgentTool, DevframeDefinition, DevToolsHost, DevToolsNodeContext } from 'devframe/types' +import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types' import type { GenericSchema } from 'valibot' import { homedir } from 'node:os' import process from 'node:process' @@ -50,7 +50,7 @@ export interface McpServerHandle { * @internal */ export function buildMcpServerFromContext( - ctx: DevToolsNodeContext, + ctx: DevframeNodeContext, options: { serverName: string, serverVersion: string, exposeSharedState: boolean | ((k: string) => boolean) }, ): { server: Server, dispose: () => void } { const server = new Server( @@ -104,12 +104,12 @@ export async function createMcpServer( if (transport !== 'stdio') throw diagnostics.DF0017({ transport, reason: 'Only stdio transport is supported in this release.' }) - const host: DevToolsHost = { + const host: DevframeHost = { mountStatic: () => { /* MCP has no static surface */ }, resolveOrigin: () => 'mcp://devframe', getStorageDir: scope => scope === 'workspace' - ? join(process.cwd(), `node_modules/.${definition.id}/devtools`) - : join(homedir(), `.${definition.id}/devtools`), + ? join(process.cwd(), `node_modules/.${definition.id}/devframe`) + : join(homedir(), `.${definition.id}/devframe`), } const ctx = await createHostContext({ @@ -145,7 +145,7 @@ export async function createMcpServer( } } -function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void { +function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void { server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx)) return { tools } @@ -180,7 +180,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void { function registerResourceHandlers( server: Server, - ctx: DevToolsNodeContext, + ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), ): void { server.setRequestHandler(ListResourcesRequestSchema, async () => { @@ -242,7 +242,7 @@ function registerResourceHandlers( }) } -function projectTool(tool: AgentTool, ctx: DevToolsNodeContext): Record { +function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Record { const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx) const outputSchema = tool.outputSchema ?? computeOutputSchema(tool, ctx) return { @@ -259,20 +259,20 @@ function projectTool(tool: AgentTool, ctx: DevToolsNodeContext): Record | undefined + const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined if (!def) return { type: 'object', properties: {} } const args = def.args as readonly GenericSchema[] | undefined return valibotArgsToJsonSchema(args).schema } -function computeOutputSchema(tool: AgentTool, ctx: DevToolsNodeContext): unknown { +function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown { if (tool.kind !== 'rpc' || !tool.rpcName) return undefined - const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined + const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined if (!def) return undefined return valibotReturnToJsonSchema(def.returns as GenericSchema | undefined) diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 3531de0..d94b4f8 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -1,6 +1,6 @@ -import { getDevToolsRpcClient } from './rpc' +import { getDevframeRpcClient } from './rpc' export * from './rpc' export * from './rpc-streaming' -export const connectDevframe = getDevToolsRpcClient +export const connectDevframe = getDevframeRpcClient diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index 12e9574..09bb4af 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -1,9 +1,9 @@ import type { RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' -import type { DevToolsRpcClient } from './rpc' +import type { DevframeRpcClient } from './rpc' import { createSharedState } from 'devframe/utils/shared-state' -export function createRpcSharedStateClientHost(rpc: DevToolsRpcClient): RpcSharedStateHost { +export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost { const sharedState = new Map>() const initialValues = new Map() const keyAddedListeners = new Set<(key: string) => void>() diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts index 9242c63..ecdff34 100644 --- a/packages/devframe/src/client/rpc-static.ts +++ b/packages/devframe/src/client/rpc-static.ts @@ -1,5 +1,5 @@ -import type { DevToolsRpcClientMode } from './rpc' -import { DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME } from 'devframe/constants' +import type { DevframeRpcClientMode } from './rpc' +import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME } from 'devframe/constants' import { createStaticRpcCaller } from './static-rpc' export interface CreateStaticRpcClientModeOptions { @@ -8,8 +8,8 @@ export interface CreateStaticRpcClientModeOptions { export async function createStaticRpcClientMode( options: CreateStaticRpcClientModeOptions, -): Promise { - const manifest = await options.fetchJsonFromBases(DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME) +): Promise { + const manifest = await options.fetchJsonFromBases(DEVFRAME_RPC_DUMP_MANIFEST_FILENAME) const staticCaller = createStaticRpcCaller(manifest, options.fetchJsonFromBases) return { diff --git a/packages/devframe/src/client/rpc-streaming.ts b/packages/devframe/src/client/rpc-streaming.ts index ebb6c55..7264658 100644 --- a/packages/devframe/src/client/rpc-streaming.ts +++ b/packages/devframe/src/client/rpc-streaming.ts @@ -1,5 +1,5 @@ import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' -import type { DevToolsRpcClient } from './rpc' +import type { DevframeRpcClient } from './rpc' import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' const STREAM_KEY_SEPARATOR = '\x1F' @@ -41,7 +41,7 @@ export interface RpcStreamingClientHost { * registers the two `:chunk` / `:end` event handlers once, then per-stream * state lives in a `Map`. */ -export function createRpcStreamingClientHost(rpc: DevToolsRpcClient): RpcStreamingClientHost { +export function createRpcStreamingClientHost(rpc: DevframeRpcClient): RpcStreamingClientHost { const readers = new Map>() const uploads = new Map>() diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index b0b99d2..54d8ccb 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -1,5 +1,5 @@ -import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter } from 'devframe/types' -import type { DevToolsClientRpcHost, DevToolsRpcClientMode, DevToolsRpcClientOptions, RpcClientEvents } from './rpc' +import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter } from 'devframe/types' +import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { promiseWithResolver } from 'devframe/utils/promise' @@ -9,9 +9,9 @@ export interface CreateWsRpcClientModeOptions { authToken: string connectionMeta: ConnectionMeta events: EventEmitter - clientRpc: DevToolsClientRpcHost - rpcOptions?: DevToolsRpcClientOptions['rpcOptions'] - wsOptions?: DevToolsRpcClientOptions['wsOptions'] + clientRpc: DevframeClientRpcHost + rpcOptions?: DevframeRpcClientOptions['rpcOptions'] + wsOptions?: DevframeRpcClientOptions['wsOptions'] } function isNumeric(str: string | number | undefined) { @@ -22,7 +22,7 @@ function isNumeric(str: string | number | undefined) { export function createWsRpcClientMode( options: CreateWsRpcClientModeOptions, -): DevToolsRpcClientMode { +): DevframeRpcClientMode { const { authToken, connectionMeta, @@ -46,7 +46,7 @@ export function createWsRpcClientMode( for (const name of connectionMeta.jsonSerializableMethods ?? []) definitions.set(name, { jsonSerializable: true }) - const serverRpc = createRpcClient( + const serverRpc = createRpcClient( clientRpc.functions, { channel: createWsRpcChannel({ @@ -84,7 +84,7 @@ export function createWsRpcClientMode( info.device.type, ].filter(i => i).join(' ') - const result = await serverRpc.$call('vite:anonymous:auth', { + const result = await serverRpc.$call('devframe:anonymous:auth', { authToken: token, ua, origin: location.origin, diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index d090986..d5c3bf5 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -1,10 +1,10 @@ import type { BirpcOptions, BirpcReturn } from 'birpc' import type { RpcCacheOptions, RpcFunctionsCollector } from 'devframe/rpc' import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' -import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' +import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' import type { RpcStreamingClientHost } from './rpc-streaming' import { - DEVTOOLS_CONNECTION_META_FILENAME, + DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' @@ -14,23 +14,23 @@ import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' import { createWsRpcClientMode } from './rpc-ws' -export interface DevToolsRpcContext { +export interface DevframeRpcContext { /** * The RPC client to interact with the server */ - readonly rpc: DevToolsRpcClient + readonly rpc: DevframeRpcClient } -export type DevToolsClientRpcHost = RpcFunctionsCollector +export type DevframeClientRpcHost = RpcFunctionsCollector export interface RpcClientEvents { 'rpc:is-trusted:updated': (isTrusted: boolean) => void } -const CONNECTION_META_KEY = '__VITE_DEVTOOLS_CONNECTION_META__' -const CONNECTION_AUTH_TOKEN_KEY = '__VITE_DEVTOOLS_CONNECTION_AUTH_TOKEN__' +const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' -export interface DevToolsRpcClientOptions { +export interface DevframeRpcClientOptions { connectionMeta?: ConnectionMeta baseURL?: string | string[] /** @@ -38,15 +38,15 @@ export interface DevToolsRpcClientOptions { */ authToken?: string wsOptions?: Partial - rpcOptions?: Partial> + rpcOptions?: Partial> cacheOptions?: boolean | Partial } -export type DevToolsRpcClientCall = BirpcReturn['$call'] -export type DevToolsRpcClientCallEvent = BirpcReturn['$callEvent'] -export type DevToolsRpcClientCallOptional = BirpcReturn['$callOptional'] +export type DevframeRpcClientCall = BirpcReturn['$call'] +export type DevframeRpcClientCallEvent = BirpcReturn['$callEvent'] +export type DevframeRpcClientCallOptional = BirpcReturn['$callOptional'] -export interface DevToolsRpcClient { +export interface DevframeRpcClient { /** * The events of the client */ @@ -83,19 +83,19 @@ export interface DevToolsRpcClient { /** * Call a RPC function on the server */ - call: DevToolsRpcClientCall + call: DevframeRpcClientCall /** * Call a RPC event on the server, and does not expect a response */ - callEvent: DevToolsRpcClientCallEvent + callEvent: DevframeRpcClientCallEvent /** * Call a RPC optional function on the server */ - callOptional: DevToolsRpcClientCallOptional + callOptional: DevframeRpcClientCallOptional /** * The client RPC host */ - client: DevToolsClientRpcHost + client: DevframeClientRpcHost /** * The shared state host @@ -113,14 +113,14 @@ export interface DevToolsRpcClient { cacheManager: RpcCacheManager } -export interface DevToolsRpcClientMode { +export interface DevframeRpcClientMode { readonly isTrusted: boolean - ensureTrusted: DevToolsRpcClient['ensureTrusted'] - requestTrust: DevToolsRpcClient['requestTrust'] - requestTrustWithToken: DevToolsRpcClient['requestTrustWithToken'] - call: DevToolsRpcClient['call'] - callEvent: DevToolsRpcClient['callEvent'] - callOptional: DevToolsRpcClient['callOptional'] + ensureTrusted: DevframeRpcClient['ensureTrusted'] + requestTrust: DevframeRpcClient['requestTrust'] + requestTrustWithToken: DevframeRpcClient['requestTrustWithToken'] + call: DevframeRpcClient['call'] + callEvent: DevframeRpcClient['callEvent'] + callOptional: DevframeRpcClient['callOptional'] } function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { @@ -168,9 +168,9 @@ function findConnectionMetaFromWindows(): ConnectionMeta | undefined { } } -export async function getDevToolsRpcClient( - options: DevToolsRpcClientOptions = {}, -): Promise { +export async function getDevframeRpcClient( + options: DevframeRpcClientOptions = {}, +): Promise { // Default to a relative base — the SPA owns its mount path at runtime, // so the connection meta and dump shards live alongside `index.html`. // Embedded surfaces that run inside a host page (e.g. a webcomponent @@ -202,7 +202,7 @@ export async function getDevToolsRpcClient( const errors: Error[] = [] for (const base of bases) { try { - connectionMeta = await fetch(resolveBasePath(base, DEVTOOLS_CONNECTION_META_FILENAME)) + connectionMeta = await fetch(resolveBasePath(base, DEVFRAME_CONNECTION_META_FILENAME)) .then(r => r.json()) as ConnectionMeta resolvedBaseURL = base ;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta @@ -220,11 +220,11 @@ export async function getDevToolsRpcClient( } const cacheManager = new RpcCacheManager({ functions: [], ...(typeof options.cacheOptions === 'object' ? options.cacheOptions : {}) }) - const context: DevToolsRpcContext = { + const context: DevframeRpcContext = { rpc: undefined!, } const authToken = getConnectionAuthTokenFromWindows(options.authToken) - const clientRpc: DevToolsClientRpcHost = new RpcFunctionsCollectorBase(context) + const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) async function fetchJsonFromBases(path: string): Promise { const candidates = [ @@ -283,7 +283,7 @@ export async function getDevToolsRpcClient( wsOptions: options.wsOptions, }) - const rpc: DevToolsRpcClient = { + const rpc: DevframeRpcClient = { events, get isTrusted() { return mode.isTrusted @@ -316,7 +316,7 @@ export async function getDevToolsRpcClient( // Listen for auth updates from other tabs (e.g., auth URL page). // Channel name kept for cross-tab interop with the Vite DevTools auth page. try { - const bc = new BroadcastChannel('vite-devtools-auth') + const bc = new BroadcastChannel('devframe-auth') bc.onmessage = (event) => { if (event.data?.type === 'auth-update' && event.data.authToken) { rpc.requestTrustWithToken(event.data.authToken) diff --git a/packages/devframe/src/client/static-rpc.test.ts b/packages/devframe/src/client/static-rpc.test.ts index 7008268..e91fe3a 100644 --- a/packages/devframe/src/client/static-rpc.test.ts +++ b/packages/devframe/src/client/static-rpc.test.ts @@ -1,11 +1,11 @@ -import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { DEVFRAME_RPC_DUMP_DIRNAME } from 'devframe/constants' import { hash } from 'devframe/utils/hash' import { structuredCloneStringify } from 'devframe/utils/structured-clone' import { describe, expect, it } from 'vitest' import { createStaticRpcCaller } from './static-rpc' -const DEMO_STATIC_VERSION_PATH = `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~version.static.json` -const DEMO_QUERY_BASE_PATH = `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~get-item` +const DEMO_STATIC_VERSION_PATH = `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~version.static.json` +const DEMO_QUERY_BASE_PATH = `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~get-item` const DEMO_QUERY_RECORDS_PATH = `${DEMO_QUERY_BASE_PATH}.record` const DEMO_QUERY_FALLBACK_PATH = `${DEMO_QUERY_BASE_PATH}.fallback.json` @@ -80,7 +80,7 @@ describe('createStaticRpcCaller', () => { await expect(caller.callOptional('demo:missing', [])).resolves.toBeUndefined() await expect(caller.callEvent('demo:missing', [])).resolves.toBeUndefined() - await expect(caller.call('demo:missing', [])).rejects.toThrow('[devtools-rpc] Function "demo:missing" not found in dump store') + await expect(caller.call('demo:missing', [])).rejects.toThrow('[devframe-rpc] Function "demo:missing" not found in dump store') }) it('treats callEvent as no-op in static mode even for known methods', async () => { @@ -117,7 +117,7 @@ describe('createStaticRpcCaller', () => { { 'demo:graph': { type: 'static', - path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~graph.static.json`, + path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~graph.static.json`, serialization: 'structured-clone', }, }, @@ -161,7 +161,7 @@ describe('createStaticRpcCaller', () => { { 'demo:legacy-static': { type: 'static', - path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~legacy.static.json`, + path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~legacy.static.json`, // no `serialization` field — must default to JSON parsing }, }, diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts index 796d366..06ba74d 100644 --- a/packages/devframe/src/client/static-rpc.ts +++ b/packages/devframe/src/client/static-rpc.ts @@ -102,14 +102,14 @@ export function createStaticRpcCaller( async function call(functionName: string, args: any[]) { if (!(functionName in manifest)) { - throw new Error(`[devtools-rpc] Function "${functionName}" not found in dump store`) + throw new Error(`[devframe-rpc] Function "${functionName}" not found in dump store`) } const entry = manifest[functionName] if (isStaticEntry(entry)) { if (args.length > 0) { throw new Error( - `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + `[devframe-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, ) } return await loadStatic(entry) @@ -130,7 +130,7 @@ export function createStaticRpcCaller( } throw new Error( - `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + `[devframe-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, ) } @@ -139,7 +139,7 @@ export function createStaticRpcCaller( } throw new Error( - `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + `[devframe-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, ) } diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index c04f0b3..106fd8e 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -1,17 +1,17 @@ -// DevTools runtime routes and static output conventions. -export const DEVTOOLS_MOUNT_PATH = '/__devtools/' -export const DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH = '/__devtools' -export const DEVTOOLS_DIRNAME = '__devtools' +// Devframe runtime routes and static output conventions. +export const DEVFRAME_MOUNT_PATH = '/__devframe/' +export const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH = '/__devframe' +export const DEVFRAME_DIRNAME = '__devframe' -export const DEVTOOLS_CONNECTION_META_FILENAME = '__connection.json' -export const DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' -export const DEVTOOLS_DOCK_IMPORTS_FILENAME = '__client-imports.js' -export const DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID = '/__devtools-client-imports.js' -export const DEVTOOLS_RPC_DUMP_DIRNAME = '__rpc-dump' +export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' +export const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' +export const DEVFRAME_DOCK_IMPORTS_FILENAME = '__client-imports.js' +export const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID = '/__devframe-client-imports.js' +export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' /** * URL fragment / query parameter name carrying the remote dock * connection descriptor (defined as `RemoteConnectionInfo` in * `@vitejs/devtools-kit`) injected into remote-UI iframe dock URLs. */ -export const REMOTE_CONNECTION_KEY = 'vite-devtools-kit-connection' +export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' diff --git a/packages/devframe/src/define.ts b/packages/devframe/src/define.ts index 64af131..3de788f 100644 --- a/packages/devframe/src/define.ts +++ b/packages/devframe/src/define.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' import { createDefineWrapperWithContext } from 'devframe/rpc' -export const defineRpcFunction = createDefineWrapperWithContext() +export const defineRpcFunction = createDefineWrapperWithContext() diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 45d1fd2..647213c 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -3,7 +3,7 @@ import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' import { resolveBasePath } from '../adapters/_shared' import { createDevServer, resolveDevServerPort } from '../adapters/dev' -import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' import { diagnostics } from '../node/diagnostics' export interface ViteDevBridgeOptions { @@ -110,7 +110,7 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio return } - const metaPath = `${base}${DEVTOOLS_CONNECTION_META_FILENAME}` + const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` server.middlewares.use(metaPath, (_req: unknown, res: any) => { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ backend: 'websocket', websocket: port })) diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index d0dac66..ab8e152 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -1,17 +1,17 @@ import type { RpcFunctionDefinitionAnyWithContext } from '../../rpc/types' -import type { DevToolsNodeContext } from '../../types/context' +import type { DevframeNodeContext } from '../../types/context' import { describe, expect, it, vi } from 'vitest' -import { DevToolsAgentHost } from '../host-agent' +import { DevframeAgentHost } from '../host-agent' import { RpcFunctionsHost } from '../host-functions' -function createContext(): DevToolsNodeContext { - const ctx = {} as DevToolsNodeContext +function createContext(): DevframeNodeContext { + const ctx = {} as DevframeNodeContext ctx.rpc = new RpcFunctionsHost(ctx) - ctx.agent = new DevToolsAgentHost(ctx) + ctx.agent = new DevframeAgentHost(ctx) return ctx } -function rpcDef(def: RpcFunctionDefinitionAnyWithContext): RpcFunctionDefinitionAnyWithContext { +function rpcDef(def: RpcFunctionDefinitionAnyWithContext): RpcFunctionDefinitionAnyWithContext { return def } diff --git a/packages/devframe/src/node/__tests__/host-functions.test.ts b/packages/devframe/src/node/__tests__/host-functions.test.ts index 028aad9..e21f145 100644 --- a/packages/devframe/src/node/__tests__/host-functions.test.ts +++ b/packages/devframe/src/node/__tests__/host-functions.test.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' import { defineRpcFunction } from 'devframe' import { describe, expect, it } from 'vitest' import { RpcFunctionsHost } from '../host-functions' @@ -11,7 +11,7 @@ const returnV2 = async () => 'v2' const setupWith = (handler: () => Promise) => async () => ({ handler }) describe('rpcFunctionsHost', () => { - const mockContext = {} as DevToolsNodeContext + const mockContext = {} as DevframeNodeContext describe('register() collision detection', () => { it('should register a new RPC function successfully', () => { @@ -131,7 +131,7 @@ describe('rpcFunctionsHost', () => { describe('broadcast() without rpc group', () => { it('should not throw in build mode', async () => { - const host = new RpcFunctionsHost({ mode: 'build' } as DevToolsNodeContext) + const host = new RpcFunctionsHost({ mode: 'build' } as DevframeNodeContext) await expect(host.broadcast({ method: 'devframe:terminals:updated', args: [], @@ -139,7 +139,7 @@ describe('rpcFunctionsHost', () => { }) it('should not throw in dev mode when rpc group is not yet set', async () => { - const host = new RpcFunctionsHost({ mode: 'dev' } as DevToolsNodeContext) + const host = new RpcFunctionsHost({ mode: 'dev' } as DevframeNodeContext) await expect(host.broadcast({ method: 'devframe:terminals:updated', args: [], diff --git a/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts b/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts index d125765..d698213 100644 --- a/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts +++ b/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts @@ -1,8 +1,8 @@ -import type { DevToolsHost } from '../../types/host' +import type { DevframeHost } from '../../types/host' import { describe, expect, it } from 'vitest' import { createHostContext } from '../context' -function nullHost(): DevToolsHost { +function nullHost(): DevframeHost { return { mountStatic: () => { /* no-op */ }, resolveOrigin: () => 'http://localhost:0', diff --git a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts index e5e95db..48f7727 100644 --- a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts +++ b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' import { AsyncLocalStorage } from 'node:async_hooks' import { createRpcStreamingClientHost } from 'devframe/client' import { createRpcClient } from 'devframe/rpc/client' @@ -24,12 +24,12 @@ interface Harness { async function bootHost(): Promise { const port = allocatePort() - const mockContext = {} as DevToolsNodeContext + const mockContext = {} as DevframeNodeContext const rpcHost = new RpcFunctionsHost(mockContext) const asyncStorage = new AsyncLocalStorage() - const rpcGroup = createRpcServer( + const rpcGroup = createRpcServer( rpcHost.functions, { rpcOptions: { @@ -70,13 +70,13 @@ async function bootHost(): Promise { } interface FakeClient { - rpc: ReturnType> + rpc: ReturnType> streaming: ReturnType close: () => void } function bootClient(port: number): FakeClient { - // Mimic the minimal `DevToolsRpcClient` surface that + // Mimic the minimal `DevframeRpcClient` surface that // `createRpcStreamingClientHost` uses (events, isTrusted, callEvent, // client.register). const listeners = new Set<(trusted: boolean) => void>() @@ -97,7 +97,7 @@ function bootClient(port: number): FakeClient { }, } - const rpc = createRpcClient( + const rpc = createRpcClient( clientFns, { channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), diff --git a/packages/devframe/src/node/__tests__/storage.test.ts b/packages/devframe/src/node/__tests__/storage.test.ts index 8fbf93f..49f511b 100644 --- a/packages/devframe/src/node/__tests__/storage.test.ts +++ b/packages/devframe/src/node/__tests__/storage.test.ts @@ -10,7 +10,7 @@ function wait(ms: number) { describe('createStorage', () => { it('falls back to initial value when persisted JSON is invalid', async () => { - const dir = fs.mkdtempSync(join(os.tmpdir(), 'vite-devtools-storage-')) + const dir = fs.mkdtempSync(join(os.tmpdir(), 'devframe-storage-')) const filepath = join(dir, 'state.json') fs.writeFileSync(filepath, '{invalid json', 'utf-8') diff --git a/packages/devframe/src/node/auth/revoke.ts b/packages/devframe/src/node/auth/revoke.ts index 23208b1..541505f 100644 --- a/packages/devframe/src/node/auth/revoke.ts +++ b/packages/devframe/src/node/auth/revoke.ts @@ -1,7 +1,7 @@ -import type { DevToolsNodeContext } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { RpcFunctionsHost } from '../host-functions' -import type { InternalAnonymousAuthStorage } from '../internal/context' +import type { InternalAnonymousAuthStorage } from '../hub-internals/context' /** * Flip `isTrusted` to false on any live WS clients connected with `token` @@ -10,7 +10,7 @@ import type { InternalAnonymousAuthStorage } from '../internal/context' * Shared between persisted-auth revocation and remote-dock token revocation. */ export async function revokeActiveConnectionsForToken( - context: DevToolsNodeContext, + context: DevframeNodeContext, token: string, ): Promise { const rpcHost = context.rpc as unknown as RpcFunctionsHost | undefined @@ -41,7 +41,7 @@ export async function revokeActiveConnectionsForToken( * using this token that they are no longer trusted. */ export async function revokeAuthToken( - context: DevToolsNodeContext, + context: DevframeNodeContext, storage: SharedState, token: string, ): Promise { diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index f6cd43c..346a219 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,11 +1,11 @@ -import type { DevToolsNodeRpcSession } from 'devframe/types' +import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import type { InternalAnonymousAuthStorage } from '../internal/context' +import type { InternalAnonymousAuthStorage } from '../hub-internals/context' import { humanId } from 'devframe/utils/human-id' export interface PendingAuthRequest { clientAuthToken: string - session: DevToolsNodeRpcSession + session: DevframeNodeRpcSession ua: string origin: string resolve: (result: { isTrusted: boolean }) => void diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index e780f92..00dca4e 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -1,18 +1,18 @@ import type { RpcFunctionDefinitionAny } from 'devframe/rpc' -import type { DevToolsHost, DevToolsNodeContext } from 'devframe/types' +import type { DevframeHost, DevframeNodeContext } from 'devframe/types' import { diagnostics as rpcDiagnostics } from '../rpc/diagnostics' import { diagnostics as devframeDiagnostics } from './diagnostics' -import { DevToolsAgentHost } from './host-agent' -import { DevToolsDiagnosticsHost } from './host-diagnostics' +import { DevframeAgentHost } from './host-agent' +import { DevframeDiagnosticsHost } from './host-diagnostics' import { RpcFunctionsHost } from './host-functions' -import { DevToolsViewHost } from './host-views' +import { DevframeViewHost } from './host-views' import { BUILTIN_AGENT_RPC } from './rpc' export interface CreateHostContextOptions { cwd: string workspaceRoot?: string mode: 'dev' | 'build' - host: DevToolsHost + host: DevframeHost /** * Built-in RPC declarations to register on the host. Framework * adapters (vite, rolldown, cli) can pass the ones they need; the @@ -22,17 +22,17 @@ export interface CreateHostContextOptions { } /** - * Framework- and build-tool-agnostic core of the DevTools node context. + * Framework- and build-tool-agnostic core of the Devframe node context. * Wires the RPC host, view (HTTP file-serving) host, diagnostics, and * agent subsystems. Host adapters can wrap this to augment `ctx` with * extra surfaces — for example, `@vitejs/devtools-kit`'s * `createKitContext` attaches `docks`, `terminals`, `messages`, * `commands`, and `createJsonRenderer` when mounted into Vite DevTools. */ -export async function createHostContext(options: CreateHostContextOptions): Promise { +export async function createHostContext(options: CreateHostContextOptions): Promise { const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options - const context: DevToolsNodeContext = { + const context: DevframeNodeContext = { cwd, workspaceRoot, mode, @@ -41,11 +41,11 @@ export async function createHostContext(options: CreateHostContextOptions): Prom views: undefined!, diagnostics: undefined!, agent: undefined!, - } as unknown as DevToolsNodeContext + } as unknown as DevframeNodeContext const rpcHost = new RpcFunctionsHost(context) - const viewsHost = new DevToolsViewHost(context) - const diagnosticsHost = new DevToolsDiagnosticsHost(context, [devframeDiagnostics, rpcDiagnostics]) + const viewsHost = new DevframeViewHost(context) + const diagnosticsHost = new DevframeDiagnosticsHost(context, [devframeDiagnostics, rpcDiagnostics]) context.rpc = rpcHost context.views = viewsHost context.diagnostics = diagnosticsHost @@ -53,7 +53,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom // Agent host must be constructed after `rpcHost` so it can subscribe // to `onChanged` — it auto-discovers RPC functions flagged with // the `agent` field. - const agentHost = new DevToolsAgentHost(context) + const agentHost = new DevframeAgentHost(context) context.agent = agentHost // Auto-register devframe's own agent introspection RPCs. These power diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 0feadf2..ac1cbce 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -9,7 +9,7 @@ export const diagnostics = defineDiagnostics({ why: (p: { name: string }) => `RPC function "${p.name}" is not registered`, }, DF0007: { - why: 'AsyncLocalStorage is not set, it likely to be an internal bug of the DevTools foundation', + why: 'AsyncLocalStorage is not set, it likely to be an internal bug of the Devframe foundation', }, DF0008: { why: (p: { distDir: string }) => `distDir ${p.distDir} does not exist`, diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index da7d074..5ccb644 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -7,9 +7,9 @@ import type { AgentResourceInput, AgentTool, AgentToolInput, - DevToolsAgentHostEvents, - DevToolsAgentHost as DevToolsAgentHostType, - DevToolsNodeContext, + DevframeAgentHostEvents, + DevframeAgentHost as DevframeAgentHostType, + DevframeNodeContext, EventEmitter, RpcFunctionAgentOptions, } from 'devframe/types' @@ -34,15 +34,15 @@ interface RegisteredResource { * * @experimental */ -export class DevToolsAgentHost implements DevToolsAgentHostType { - public readonly events: EventEmitter = createEventEmitter() +export class DevframeAgentHost implements DevframeAgentHostType { + public readonly events: EventEmitter = createEventEmitter() private readonly tools = new Map() private readonly resources = new Map() private _rpcUnsubscribe: (() => void) | undefined constructor( - public readonly context: DevToolsNodeContext, + public readonly context: DevframeNodeContext, ) { // Watch the RPC host for new `agent`-flagged definitions. this._rpcUnsubscribe = context.rpc.onChanged(() => { @@ -205,7 +205,7 @@ export class DevToolsAgentHost implements DevToolsAgentHostType { return out } - private _findRpcDefinition(id: string): RpcFunctionDefinitionAnyWithContext | undefined { + private _findRpcDefinition(id: string): RpcFunctionDefinitionAnyWithContext | undefined { const def = this.context.rpc.definitions.get(id) if (def?.agent) return def @@ -214,7 +214,7 @@ export class DevToolsAgentHost implements DevToolsAgentHostType { private _coercePositionalArgs( args: unknown, - def: RpcFunctionDefinitionAnyWithContext, + def: RpcFunctionDefinitionAnyWithContext, ): unknown[] { if (Array.isArray(args)) return args diff --git a/packages/devframe/src/node/host-diagnostics.ts b/packages/devframe/src/node/host-diagnostics.ts index 4485317..7b5c69a 100644 --- a/packages/devframe/src/node/host-diagnostics.ts +++ b/packages/devframe/src/node/host-diagnostics.ts @@ -1,24 +1,24 @@ -import type { DevToolsDiagnosticsHost as DevToolsDiagnosticsHostType, DevToolsDiagnosticsLogger, DevToolsNodeContext } from 'devframe/types' +import type { DevframeDiagnosticsHost as DevframeDiagnosticsHostType, DevframeDiagnosticsLogger, DevframeNodeContext } from 'devframe/types' import { defineDiagnostics } from 'nostics' import { devframeReporter } from '../utils/diagnostics-reporter' -export class DevToolsDiagnosticsHost implements DevToolsDiagnosticsHostType { +export class DevframeDiagnosticsHost implements DevframeDiagnosticsHostType { private _registry: Record = {} - readonly logger: DevToolsDiagnosticsLogger = new Proxy({} as DevToolsDiagnosticsLogger, { + readonly logger: DevframeDiagnosticsLogger = new Proxy({} as DevframeDiagnosticsLogger, { get: (_, code: string) => this._registry[code], }) - readonly defineDiagnostics: DevToolsDiagnosticsHostType['defineDiagnostics'] = (opts) => { + readonly defineDiagnostics: DevframeDiagnosticsHostType['defineDiagnostics'] = (opts) => { const merged = { ...opts, reporters: [devframeReporter, ...(opts.reporters ?? [])], } as Parameters[0] - return defineDiagnostics(merged) as ReturnType + return defineDiagnostics(merged) as ReturnType } constructor( - public readonly context: DevToolsNodeContext, + public readonly context: DevframeNodeContext, initialDefinitions: Array> = [], ) { for (const d of initialDefinitions) diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index e0a3ee6..6c5b8e3 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -1,5 +1,5 @@ import type { BirpcGroup } from 'birpc' -import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsNodeRpcSessionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost, RpcStreamingHost } from 'devframe/types' +import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, 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' @@ -7,16 +7,16 @@ import { diagnostics } from './diagnostics' import { createRpcSharedStateServerHost } from './rpc-shared-state' import { createRpcStreamingServerHost } from './rpc-streaming' -const debugBroadcast = createDebug('vite:devtools:rpc:broadcast') +const debugBroadcast = createDebug('devframe:rpc:broadcast') -export class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHostType { +export class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHostType { /** * @internal */ - _rpcGroup: BirpcGroup = undefined! - _asyncStorage: AsyncLocalStorage = undefined! + _rpcGroup: BirpcGroup = undefined! + _asyncStorage: AsyncLocalStorage = undefined! - constructor(context: DevToolsNodeContext) { + constructor(context: DevframeNodeContext) { super(context) this.sharedState = createRpcSharedStateServerHost(this) @@ -33,30 +33,30 @@ export class RpcFunctionsHost extends RpcFunctionsCollectorBase, + T extends keyof DevframeRpcServerFunctions, + Args extends Parameters, >( method: T, ...args: Args - ): Promise>> { + ): Promise>> { if (!this.definitions.has(method as string)) { throw diagnostics.DF0006({ name: String(method) }) } const handler = await this.getHandler(method) return await Promise.resolve( - (handler as (...args: Args) => ReturnType)(...args), - ) as Awaited> + (handler as (...args: Args) => ReturnType)(...args), + ) as Awaited> } async broadcast< - T extends keyof DevToolsRpcClientFunctions, - Args extends Parameters, + T extends keyof DevframeRpcClientFunctions, + Args extends Parameters, >( options: RpcBroadcastOptions, ): Promise { @@ -78,7 +78,7 @@ export class RpcFunctionsHost extends RpcFunctionsCollectorBase void | Promise /** * Namespace for storage paths returned by `getStorageDir`. Workspace - * state lives under `${workspaceRoot}/node_modules/./devtools/` - * and global state under `${homedir()}/./devtools/`. Pick the + * state lives under `${workspaceRoot}/node_modules/./devframe/` + * and global state under `${homedir()}/./devframe/`. Pick the * devtool's id (or another stable, filesystem-safe identifier) so the * standalone host doesn't collide with other tools' storage. */ @@ -33,9 +33,9 @@ export interface CreateH3DevToolsHostOptions { } /** - * h3-backed {@link DevToolsHost} — used by the standalone CLI adapter. + * h3-backed {@link DevframeHost} — used by the standalone CLI adapter. */ -export function createH3DevToolsHost(options: CreateH3DevToolsHostOptions): DevToolsHost { +export function createH3DevframeHost(options: CreateH3DevframeHostOptions): DevframeHost { const workspaceRoot = options.workspaceRoot ?? process.cwd() return { mountStatic(base, distDir) { @@ -45,7 +45,7 @@ export function createH3DevToolsHost(options: CreateH3DevToolsHostOptions): DevT return options.origin }, getStorageDir(scope) { - const namespace = `.${options.appName}/devtools` + const namespace = `.${options.appName}/devframe` return scope === 'workspace' ? join(workspaceRoot, 'node_modules', namespace) : join(homedir(), namespace) diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index 7226b62..dcf704e 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -1,15 +1,15 @@ -import type { DevToolsNodeContext, DevToolsViewHost as DevToolsViewHostType } from 'devframe/types' +import type { DevframeNodeContext, DevframeViewHost as DevframeViewHostType } from 'devframe/types' import { existsSync } from 'node:fs' import { diagnostics } from './diagnostics' -export class DevToolsViewHost implements DevToolsViewHostType { +export class DevframeViewHost implements DevframeViewHostType { /** * @internal */ public buildStaticDirs: { baseUrl: string, distDir: string }[] = [] constructor( - public readonly context: DevToolsNodeContext, + public readonly context: DevframeNodeContext, ) { } diff --git a/packages/devframe/src/node/internal/context.ts b/packages/devframe/src/node/hub-internals/context.ts similarity index 90% rename from packages/devframe/src/node/internal/context.ts rename to packages/devframe/src/node/hub-internals/context.ts index 20e0081..43506b8 100644 --- a/packages/devframe/src/node/internal/context.ts +++ b/packages/devframe/src/node/hub-internals/context.ts @@ -1,4 +1,4 @@ -import type { DevToolsNodeContext } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import { humanId } from 'devframe/utils/human-id' import { join } from 'pathe' @@ -21,7 +21,7 @@ export interface RemoteTokenRecord { originLock: boolean } -export interface DevToolsInternalContext { +export interface DevframeInternalContext { storage: { auth: SharedState } @@ -55,9 +55,9 @@ export interface DevToolsInternalContext { } } -export const internalContextMap = new WeakMap() +export const internalContextMap = new WeakMap() -export function getInternalContext(context: DevToolsNodeContext): DevToolsInternalContext { +export function getInternalContext(context: DevframeNodeContext): DevframeInternalContext { if (!internalContextMap.has(context)) { const storage = createStorage({ filepath: join(context.host.getStorageDir('global'), 'auth.json'), @@ -73,7 +73,7 @@ export function getInternalContext(context: DevToolsNodeContext): DevToolsIntern void revokeActiveConnectionsForToken(context, token) } - const internalContext: DevToolsInternalContext = { + const internalContext: DevframeInternalContext = { storage: { auth: storage, }, diff --git a/packages/devframe/src/node/hub-internals/index.ts b/packages/devframe/src/node/hub-internals/index.ts new file mode 100644 index 0000000..5e3feb6 --- /dev/null +++ b/packages/devframe/src/node/hub-internals/index.ts @@ -0,0 +1,25 @@ +/** + * Public surface for first-party hub adapters (`@devframes/hub` and + * similar). Carries the primitives a hub-kit author needs to bridge a + * framework dev server into a hub context — base-path resolution and + * the remote-dock token bridge used by the hub's docks host. + * + * Stable across minor versions; treat additions or removals as breaking + * only across major versions. + */ + +export { + normalizeBasePath, + resolveBasePath, +} from '../../adapters/_shared' + +export { + getInternalContext, + internalContextMap, +} from './context' + +export type { + DevframeInternalContext, + InternalAnonymousAuthStorage, + RemoteTokenRecord, +} from './context' diff --git a/packages/devframe/src/node/internal/index.ts b/packages/devframe/src/node/internal/index.ts deleted file mode 100644 index e3c0234..0000000 --- a/packages/devframe/src/node/internal/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Reserved for `@vitejs/devtools-kit` and other first-party adapters - * that reach into devframe's private machinery (currently the - * remote-dock token bridge required by the relocated `DocksHost`). - * - * End users should not import from this subpath. The surface is - * unstable and may change without a major bump. - * - * @internal - */ - -export { - normalizeBasePath, - resolveBasePath, -} from '../../adapters/_shared' - -export { - getInternalContext, - internalContextMap, -} from './context' - -export type { - DevToolsInternalContext, - InternalAnonymousAuthStorage, - RemoteTokenRecord, -} from './context' diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index 329862b..ef4b2da 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -1,11 +1,11 @@ -import type { DevToolsRpcSharedStates, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' +import type { DevframeRpcSharedStates, 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 { diagnostics } from './diagnostics' -const debug = createDebug('vite:devtools:rpc:state:changed') -const debugSubscribe = createDebug('vite:devtools:rpc:state:subscribe') +const debug = createDebug('devframe:rpc:state:changed') +const debugSubscribe = createDebug('devframe:rpc:state:subscribe') export function createRpcSharedStateServerHost( rpc: RpcFunctionsHost, @@ -97,7 +97,7 @@ export function createRpcSharedStateServerHost( handler: async (key: string) => { if (!sharedState.has(key)) return undefined - const state = await host.get(key as keyof DevToolsRpcSharedStates) + const state = await host.get(key as keyof DevframeRpcSharedStates) return state.value() }, // Pre-compute snapshots for the build-mode static dump so the SPA @@ -111,7 +111,7 @@ export function createRpcSharedStateServerHost( name: 'devframe:rpc:server-state:set', type: 'query', handler: async (key: string, value: any, syncId: string) => { - const state = await host.get(key as keyof DevToolsRpcSharedStates, { + const state = await host.get(key as keyof DevframeRpcSharedStates, { initialValue: value, }) state.mutate(() => value, syncId) @@ -124,7 +124,7 @@ export function createRpcSharedStateServerHost( handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { if (!sharedState.has(key)) return - const state = await host.get(key as keyof DevToolsRpcSharedStates) + const state = await host.get(key as keyof DevframeRpcSharedStates) state.patch(patches, syncId) }, }) diff --git a/packages/devframe/src/node/rpc-streaming.ts b/packages/devframe/src/node/rpc-streaming.ts index 023775e..3e1d2d8 100644 --- a/packages/devframe/src/node/rpc-streaming.ts +++ b/packages/devframe/src/node/rpc-streaming.ts @@ -1,5 +1,5 @@ import type { - DevToolsNodeRpcSessionMeta, + DevframeNodeRpcSessionMeta, RpcFunctionsHost, RpcStreamingChannel, RpcStreamingChannelOptions, @@ -10,7 +10,7 @@ import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-c import { createDebug } from 'obug' import { diagnostics } from './diagnostics' -const debug = createDebug('vite:devtools:rpc:streaming') +const debug = createDebug('devframe:rpc:streaming') const STREAM_KEY_SEPARATOR = '\x1F' @@ -20,7 +20,7 @@ function streamKey(channel: string, id: string): string { interface ServerStreamRecord { sink: StreamSink - subscribers: Set + subscribers: Set unbinders: (() => void)[] /** Timer scheduled when stream closes with no subscribers; cleared on resubscribe. */ retentionTimer?: ReturnType @@ -29,7 +29,7 @@ interface ServerStreamRecord { interface ServerInboundRecord { reader: StreamReader /** First session that wrote to this inbound — locks ownership for cleanup. */ - uploaderMeta?: DevToolsNodeRpcSessionMeta + uploaderMeta?: DevframeNodeRpcSessionMeta } interface ChannelState { @@ -254,7 +254,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin args: [name, sink.id, seq, chunk], event: true, optional: true, - filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + filter: client => record.subscribers.has(client.$meta as DevframeNodeRpcSessionMeta), }) }), ) @@ -265,7 +265,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin args: [name, sink.id, error], event: true, optional: true, - filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + filter: client => record.subscribers.has(client.$meta as DevframeNodeRpcSessionMeta), }) maybeFreeStream(state, sink.id) }), @@ -332,7 +332,7 @@ export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamin return { create: createChannel, - _onSessionDisconnected(meta: DevToolsNodeRpcSessionMeta) { + _onSessionDisconnected(meta: DevframeNodeRpcSessionMeta) { // Outbound: drop subscriber, abort if last one drops. if (meta.subscribedStreams) { for (const key of meta.subscribedStreams) { diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts index 7fd3f00..413ed6c 100644 --- a/packages/devframe/src/node/rpc/index.ts +++ b/packages/devframe/src/node/rpc/index.ts @@ -20,7 +20,7 @@ export const BUILTIN_AGENT_RPC = [ ] as const declare module 'devframe/types' { - interface DevToolsRpcServerFunctions { + interface DevframeRpcServerFunctions { 'devframe:agent:list-tools': () => Promise 'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise 'devframe:agent:list-resources': () => Promise diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index b32dfa8..5ac1729 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -1,5 +1,5 @@ import type { BirpcGroup } from 'birpc' -import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' import type { WebSocketServer } from 'ws' import type { RpcFunctionsHost } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' @@ -8,9 +8,10 @@ import { createRpcServer } from 'devframe/rpc/server' import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' import { H3, toNodeHandler } from 'h3' import { WebSocketServer as WSServer } from 'ws' +import { getInternalContext } from './hub-internals/context' export interface StartHttpAndWsOptions { - context: DevToolsNodeContext + context: DevframeNodeContext host?: string port: number /** @@ -44,7 +45,7 @@ export interface StartedServer { port: number app: H3 wss: WebSocketServer - rpcGroup: BirpcGroup + rpcGroup: BirpcGroup close: () => Promise } @@ -61,9 +62,9 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise() + const asyncStorage = new AsyncLocalStorage() - const rpcGroup = createRpcServer( + const rpcGroup = createRpcServer( rpcHost.functions, { rpcOptions: { @@ -102,15 +103,15 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { const session = rpcHost.getCurrentRpcSession() @@ -125,14 +126,21 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise resolveListen()) }) - const origin = `http://${bindHost}:${port}` + const address = httpServer.address() + const resolvedPort = typeof address === 'object' && address ? address.port : port + const origin = `http://${bindHost}:${resolvedPort}` + const internal = getInternalContext(context) + const wsUrl = origin.replace(/^http/, 'ws') + internal.wsEndpoint = { + url: wsUrl, + } if (options.onReady) - await options.onReady({ origin, port, app }) + await options.onReady({ origin, port: resolvedPort, app }) return { origin, - port, + port: resolvedPort, app, wss, rpcGroup, @@ -144,6 +152,8 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise(r => wss.close(() => r())) await new Promise(r => httpServer.close(() => r())) + if (getInternalContext(context).wsEndpoint?.url === wsUrl) + getInternalContext(context).wsEndpoint = undefined }, } } diff --git a/packages/devframe/src/rpc/dump/__tests__/static.test.ts b/packages/devframe/src/rpc/dump/__tests__/static.test.ts index 1b2369a..1b1194a 100644 --- a/packages/devframe/src/rpc/dump/__tests__/static.test.ts +++ b/packages/devframe/src/rpc/dump/__tests__/static.test.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { DEVFRAME_RPC_DUMP_DIRNAME } from 'devframe/constants' import { strictJsonStringify } from 'devframe/rpc' import { structuredCloneDeserialize, structuredCloneStringify } from 'devframe/utils/structured-clone' import { describe, expect, it } from 'vitest' @@ -15,7 +15,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getVersion], {}) - const expectedPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-version.static.json` + const expectedPath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~json-version.static.json` expect(result.manifest['test:json-version']).toEqual({ type: 'static', @@ -33,7 +33,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getVersion], {}) - const expectedPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~get-version.static.json` + const expectedPath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~get-version.static.json` expect(result.manifest['test:get-version']).toEqual({ type: 'static', @@ -63,7 +63,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getItem], {}) - const basePath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~get-item` + const basePath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~get-item` const manifest = result.manifest['test:get-item'] as { type: 'query' records: Record @@ -114,7 +114,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getGraph], {}) - const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~graph.static.json` + const path = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~graph.static.json` const file = result.files[path]! expect(file.serialization).toBe('structured-clone') @@ -136,7 +136,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getMap], {}) - const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~roundtrip-map.static.json` + const path = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~roundtrip-map.static.json` const file = result.files[path]! // Server side: write to disk as sc-encoded text. @@ -160,7 +160,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getEntries], {}) - const fallbackPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~entries.fallback.json` + const fallbackPath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~entries.fallback.json` const fallback = result.files[fallbackPath]! expect(fallback.serialization).toBe('structured-clone') @@ -188,7 +188,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getList], {}) - const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-list.static.json` + const path = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~json-list.static.json` const file = result.files[path]! expect(file.serialization).toBe('json') @@ -207,7 +207,7 @@ describe('collectStaticRpcDump', () => { }) const result = await collectStaticRpcDump([getMapJson], {}) - const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~bad-json.static.json` + const path = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~bad-json.static.json` const file = result.files[path]! // collectStaticRpcDump records the value as-is; the strict diff --git a/packages/devframe/src/rpc/dump/static.ts b/packages/devframe/src/rpc/dump/static.ts index 2120d05..01b4723 100644 --- a/packages/devframe/src/rpc/dump/static.ts +++ b/packages/devframe/src/rpc/dump/static.ts @@ -1,6 +1,6 @@ import type { RpcDumpRecord, RpcFunctionDefinitionAny } from '../types' import { - DEVTOOLS_RPC_DUMP_DIRNAME, + DEVFRAME_RPC_DUMP_DIRNAME, } from 'devframe/constants' import { getRpcHandler } from '../handler' import { dumpFunctions } from './collect' @@ -48,15 +48,15 @@ function makeDumpKey(name: string): string { } function makeStaticPath(name: string): string { - return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.static.json` + return `${DEVFRAME_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.static.json` } function makeQueryRecordPath(name: string, hash: string): string { - return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.record.${hash}.json` + return `${DEVFRAME_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.record.${hash}.json` } function makeQueryFallbackPath(name: string): string { - return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.fallback.json` + return `${DEVFRAME_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.fallback.json` } async function resolveRecord(record: RpcDumpRecord | (() => Promise)): Promise { diff --git a/packages/devframe/src/rpc/transports/ws-client.ts b/packages/devframe/src/rpc/transports/ws-client.ts index e246309..230ff94 100644 --- a/packages/devframe/src/rpc/transports/ws-client.ts +++ b/packages/devframe/src/rpc/transports/ws-client.ts @@ -30,7 +30,7 @@ const EMPTY_DEFS: ReadonlyMap> - onConnected?: (ws: WebSocket, req: IncomingMessage, meta: DevToolsNodeRpcSessionMeta) => void - onDisconnected?: (ws: WebSocket, meta: DevToolsNodeRpcSessionMeta) => void + onConnected?: (ws: WebSocket, req: IncomingMessage, meta: DevframeNodeRpcSessionMeta) => void + onDisconnected?: (ws: WebSocket, meta: DevframeNodeRpcSessionMeta) => void /** Override the default per-call serializer. Most callers should leave this unset. */ serialize?: ChannelOptions['serialize'] /** Override the default per-call deserializer. Most callers should leave this unset. */ @@ -98,7 +98,7 @@ export function attachWsRpcTransport< } wss.on('connection', (ws, req) => { - const meta: DevToolsNodeRpcSessionMeta = { + const meta: DevframeNodeRpcSessionMeta = { id: sessionId++, ws, subscribedStates: new Set(), diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index 0d8370c..d84c52e 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -8,7 +8,7 @@ import { attachWsRpcTransport } from './ws-server' vi.stubGlobal('WebSocket', WebSocket) -describe('devtools rpc', () => { +describe('devframe rpc', () => { it('should work w/ ws transport', async () => { const PORT = 3333 // Use 127.0.0.1 on both client and server so they agree on the diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index a966a3a..bb559bb 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -33,7 +33,7 @@ export interface AgentTool { } /** - * Input accepted by `DevToolsAgentHost.registerTool()`. Handler is + * Input accepted by `DevframeAgentHost.registerTool()`. Handler is * stripped from the serializable `AgentTool` projection. * * @experimental @@ -53,7 +53,7 @@ export interface AgentToolInput { /** * Serializable description of an agent-readable resource. Resources - * surface structured or textual snapshots of devtools state. + * surface structured or textual snapshots of devframe state. * * @experimental */ @@ -68,7 +68,7 @@ export interface AgentResource { } /** - * Input accepted by `DevToolsAgentHost.registerResource()`. + * Input accepted by `DevframeAgentHost.registerResource()`. * * @experimental */ @@ -115,11 +115,11 @@ export interface AgentHandle { } /** - * Events emitted by `DevToolsAgentHost`. + * Events emitted by `DevframeAgentHost`. * * @experimental */ -export interface DevToolsAgentHostEvents { +export interface DevframeAgentHostEvents { 'agent:tool:registered': (tool: AgentTool) => void 'agent:tool:unregistered': (id: string) => void 'agent:resource:registered': (resource: AgentResource) => void @@ -140,8 +140,8 @@ export interface DevToolsAgentHostEvents { * @experimental The agent-native surface is experimental and may change * without a major version bump until it stabilizes. */ -export interface DevToolsAgentHost { - readonly events: EventEmitter +export interface DevframeAgentHost { + readonly events: EventEmitter /** * Register a tool not backed by an RPC function. Use this when you diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index a5c4b71..2fa2761 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -1,9 +1,9 @@ -import type { DevToolsAgentHost } from './agent' -import type { DevToolsDiagnosticsHost } from './diagnostics' -import type { DevToolsHost } from './host' -import type { DevToolsViewHost } from './views' +import type { DevframeAgentHost } from './agent' +import type { DevframeDiagnosticsHost } from './diagnostics' +import type { DevframeHost } from './host' +import type { DevframeViewHost } from './views' -export interface DevToolsCapabilities { +export interface DevframeCapabilities { rpc?: boolean views?: boolean } @@ -15,7 +15,7 @@ export interface DevToolsCapabilities { * `createKitContext` adds `docks`, `terminals`, `messages`, and * `commands` when mounted into Vite DevTools. */ -export interface DevToolsNodeContext { +export interface DevframeNodeContext { readonly workspaceRoot: string readonly cwd: string /** @@ -37,20 +37,20 @@ export interface DevToolsNodeContext { * Host runtime abstraction — exposes `mountStatic` / `resolveOrigin` / * `getStorageDir`. */ - host: DevToolsHost + host: DevframeHost rpc: import('./rpc').RpcFunctionsHost - views: DevToolsViewHost + views: DevframeViewHost /** * Structured diagnostics host — wraps `nostics` and lets integrations * register their own coded errors/warnings into the shared lookup. */ - diagnostics: DevToolsDiagnosticsHost + diagnostics: DevframeDiagnosticsHost /** * Agent host — aggregates the agent-exposed surface of this devtool. * * @experimental */ - agent: DevToolsAgentHost + agent: DevframeAgentHost } export interface ConnectionMeta { diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index a1e8a28..a95906d 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -1,6 +1,6 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' -import type { DevToolsNodeContext } from './context' +import type { DevframeNodeContext } from './context' export type DevframeRuntime = 'cli' | 'build' | 'spa' | 'vite' | 'embedded' @@ -120,7 +120,7 @@ export interface DevframeDefinition { spa?: boolean | Record } /** Server-side setup — the primary entrypoint. Runs in every runtime. */ - setup: (ctx: DevToolsNodeContext, info?: DevframeSetupInfo) => void | Promise + setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise /** Browser-only setup for the SPA adapter (bundled into the client). */ setupBrowser?: (ctx: DevframeBrowserContext) => void | Promise cli?: DevframeCliOptions diff --git a/packages/devframe/src/types/diagnostics.ts b/packages/devframe/src/types/diagnostics.ts index 7ad491b..03d4815 100644 --- a/packages/devframe/src/types/diagnostics.ts +++ b/packages/devframe/src/types/diagnostics.ts @@ -7,7 +7,7 @@ import type { defineDiagnostics, Diagnostic, DiagnosticDefinition } from 'nostic * types don't allow assigning a narrow-keyed result to a generically-keyed * one. The host stores them in a heterogeneous registry. */ -export type DevToolsDiagnosticsDefinition = ReturnType> +export type DevframeDiagnosticsDefinition = ReturnType> /** * The shared diagnostics lookup exposed by the host. A `Proxy` that resolves @@ -16,14 +16,14 @@ export type DevToolsDiagnosticsDefinition = ReturnType +export type DevframeDiagnosticsLogger = Record /** * Options accepted by the host's `defineDiagnostics()` factory — mirrors * `nostics`'s shape but the host pre-wires its ANSI console reporter, so * plugins typically omit `reporters`. */ -export interface DevToolsDefineDiagnosticsOptions> { +export interface DevframeDefineDiagnosticsOptions> { docsBase?: string | ((code: keyof Codes) => string | undefined) codes: Codes reporters?: ReadonlyArray<(d: Diagnostic, o?: any) => void> @@ -52,7 +52,7 @@ export interface DevToolsDefineDiagnosticsOptions>( - options: DevToolsDefineDiagnosticsOptions, + options: DevframeDefineDiagnosticsOptions, ) => ReturnType> } diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 70e5cd8..686f311 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -1,4 +1,4 @@ -// DevToolsHost — abstraction over the runtime that serves the DevTools +// DevframeHost — abstraction over the runtime that serves the Devframe // UI and RPC endpoints (Vite dev server, standalone h3 CLI server, static // snapshot, embedded, etc.). // @@ -8,10 +8,10 @@ // - packages/devframe/src/node/host-h3.ts — h3 CLI server // - (build/spa/embedded) — added as the respective adapters land -export interface DevToolsHost { +export interface DevframeHost { /** * Serve a static directory at the given URL base. Called by - * `DevToolsViewHost.hostStatic`. Implementations map this to whatever + * `DevframeViewHost.hostStatic`. Implementations map this to whatever * the underlying runtime expects (Vite middleware, h3 handler, no-op * for build snapshots). */ @@ -27,16 +27,16 @@ export interface DevToolsHost { resolveOrigin: () => string /** - * Resolve a directory the host owns for persisted devtools state. + * Resolve a directory the host owns for persisted devframe state. * Each host picks its own app-name namespace so storage doesn't - * collide between, say, the Vite host (`.vite/devtools`) and a - * standalone CLI host (`./devtools`). + * collide between, say, the Vite host (`.vite/devframe`) and a + * standalone CLI host (`./devframe`). * * - `workspace` — per-project state (settings, caches). Typically - * under `${workspaceRoot}/node_modules/./devtools/`. + * under `${workspaceRoot}/node_modules/./devframe/`. * - `global` — per-user state (auth tokens, machine-wide * preferences). Typically under - * `${homedir()}/./devtools/`. + * `${homedir()}/./devframe/`. * * Implementations should ensure the directory exists or be safe to * pass to a downstream `createStorage(...)` call that creates it diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 8d4d5e5..9403d08 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -1,7 +1,7 @@ /** * To be extended */ -export interface DevToolsRpcClientFunctions { +export interface DevframeRpcClientFunctions { /** * Streaming chunk pushed from server to subscribed clients. Wired by * `RpcStreamingHost`; do not register manually. @@ -28,7 +28,7 @@ export interface DevToolsRpcClientFunctions { /** * To be extended */ -export interface DevToolsRpcServerFunctions { +export interface DevframeRpcServerFunctions { /** * Subscribe a client to a shared-state key. Wired by * `RpcSharedStateHost`; do not register manually. @@ -97,4 +97,4 @@ export interface DevToolsRpcServerFunctions { /** * To be extended */ -export interface DevToolsRpcSharedStates {} +export interface DevframeRpcSharedStates {} diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index caed6a1..643eb9d 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -1,16 +1,16 @@ import type { BirpcReturn } from 'birpc' import type { RpcFunctionsCollectorBase } from 'devframe/rpc' -import type { DevToolsNodeRpcSessionMeta } from 'devframe/rpc/transports/ws-server' +import type { DevframeNodeRpcSessionMeta } from 'devframe/rpc/transports/ws-server' import type { SharedState } from 'devframe/utils/shared-state' import type { StreamReader, StreamSink } from 'devframe/utils/streaming-channel' -import type { DevToolsNodeContext } from './context' -import type { DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, DevToolsRpcSharedStates } from './rpc-augments' +import type { DevframeNodeContext } from './context' +import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates } from './rpc-augments' -export type { DevToolsNodeRpcSessionMeta } +export type { DevframeNodeRpcSessionMeta } -export interface DevToolsNodeRpcSession { - meta: DevToolsNodeRpcSessionMeta - rpc: BirpcReturn +export interface DevframeNodeRpcSession { + meta: DevframeNodeRpcSessionMeta + rpc: BirpcReturn } export interface RpcBroadcastOptions { @@ -18,29 +18,29 @@ export interface RpcBroadcastOptions { args: Args optional?: boolean event?: boolean - filter?: (client: BirpcReturn) => boolean | void + filter?: (client: BirpcReturn) => boolean | void } -export type RpcFunctionsHost = RpcFunctionsCollectorBase & { +export type RpcFunctionsHost = RpcFunctionsCollectorBase & { /** * Invoke a locally registered server RPC function directly. * * This bypasses transport and is useful for server-side cross-function calls. */ invokeLocal: < - T extends keyof DevToolsRpcServerFunctions, - Args extends Parameters, + T extends keyof DevframeRpcServerFunctions, + Args extends Parameters, >( method: T, ...args: Args - ) => Promise>> + ) => Promise>> /** * Broadcast a message to all connected clients */ broadcast: < - T extends keyof DevToolsRpcClientFunctions, - Args extends Parameters, + T extends keyof DevframeRpcClientFunctions, + Args extends Parameters, >( options: RpcBroadcastOptions, ) => Promise @@ -50,7 +50,7 @@ export type RpcFunctionsHost = RpcFunctionsCollectorBase DevToolsNodeRpcSession | undefined + getCurrentRpcSession: () => DevframeNodeRpcSession | undefined /** * The shared state host @@ -72,7 +72,7 @@ export interface RpcSharedStateGetOptions { } export interface RpcSharedStateHost { - get: (key: T, options?: RpcSharedStateGetOptions) => Promise> + get: (key: T, options?: RpcSharedStateGetOptions) => Promise> keys: () => string[] /** * Subscribe to new shared-state keys becoming available. Fires when @@ -180,5 +180,5 @@ export interface RpcStreamingHost { * * @internal */ - _onSessionDisconnected: (meta: DevToolsNodeRpcSessionMeta) => void + _onSessionDisconnected: (meta: DevframeNodeRpcSessionMeta) => void } diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index 42ae967..111a90f 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -1,4 +1,4 @@ -export interface DevToolsViewHost { +export interface DevframeViewHost { /** * @internal */ diff --git a/packages/devframe/test/dts-dedupe.test.ts b/packages/devframe/test/dts-dedupe.test.ts index c96cbbc..0df727e 100644 --- a/packages/devframe/test/dts-dedupe.test.ts +++ b/packages/devframe/test/dts-dedupe.test.ts @@ -6,9 +6,9 @@ import { describe, expect, it } from 'vitest' const distRoot = fileURLToPath(new URL('../dist/', import.meta.url)) const AUGMENTABLE_INTERFACES = [ - 'DevToolsRpcClientFunctions', - 'DevToolsRpcServerFunctions', - 'DevToolsRpcSharedStates', + 'DevframeRpcClientFunctions', + 'DevframeRpcServerFunctions', + 'DevframeRpcSharedStates', ] as const describe('rpc-augments dedupe', () => { diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 96f7a6f..7a455d5 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -83,7 +83,7 @@ const serverEntries = { 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', 'node/index': 'src/node/index.ts', 'node/auth': 'src/node/auth/index.ts', - 'node/internal': 'src/node/internal/index.ts', + 'node/hub-internals': 'src/node/hub-internals/index.ts', 'utils/launch-editor': 'src/utils/launch-editor.ts', 'utils/open': 'src/utils/open.ts', 'utils/serve-static': 'src/utils/serve-static.ts', diff --git a/packages/hub/LICENSE.md b/packages/hub/LICENSE.md new file mode 100644 index 0000000..09e688c --- /dev/null +++ b/packages/hub/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-PRESENT Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hub/package.json b/packages/hub/package.json new file mode 100644 index 0000000..beaa427 --- /dev/null +++ b/packages/hub/package.json @@ -0,0 +1,54 @@ +{ + "name": "@devframes/hub", + "type": "module", + "version": "0.4.1", + "description": "Framework-neutral hub layer for devframe — docks, terminals, messages, commands.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/hub", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "devframe", + "hub", + "docks", + "terminals" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./client": "./dist/client/index.mjs", + "./constants": "./dist/constants.mjs", + "./node": "./dist/node/index.mjs", + "./types": "./dist/types/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch" + }, + "peerDependencies": { + "devframe": "workspace:*" + }, + "dependencies": { + "birpc": "catalog:deps", + "nostics": "catalog:deps", + "pathe": "catalog:deps", + "perfect-debounce": "catalog:deps", + "tinyexec": "catalog:deps" + }, + "devDependencies": { + "devframe": "workspace:*", + "mlly": "catalog:build", + "tsdown": "catalog:build" + } +} diff --git a/packages/hub/src/client/client-script.ts b/packages/hub/src/client/client-script.ts new file mode 100644 index 0000000..86d2f8f --- /dev/null +++ b/packages/hub/src/client/client-script.ts @@ -0,0 +1,16 @@ +import type { DevframeMessagesClient } from '../types/messages' +import type { DockEntryState, DocksContext } from './docks' + +/** + * Context for client scripts running in dock entries + */ +export interface DockClientScriptContext extends DocksContext { + /** + * The state of the current dock entry + */ + current: DockEntryState + /** + * Messages client scoped to this dock entry's source + */ + messages: DevframeMessagesClient +} diff --git a/packages/hub/src/client/context.ts b/packages/hub/src/client/context.ts new file mode 100644 index 0000000..df79766 --- /dev/null +++ b/packages/hub/src/client/context.ts @@ -0,0 +1,12 @@ +import type { DevframeClientContext } from './docks' + +const CLIENT_CONTEXT_KEY = '__DEVFRAME_HUB_CLIENT_CONTEXT__' + +/** + * Get the global Devframe client context, or `undefined` if not yet initialized. + */ +export function getDevframeClientContext(): DevframeClientContext | undefined { + return (globalThis as any)[CLIENT_CONTEXT_KEY] +} + +export { CLIENT_CONTEXT_KEY } diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts new file mode 100644 index 0000000..a5d217c --- /dev/null +++ b/packages/hub/src/client/docks.ts @@ -0,0 +1,139 @@ +import type { DevframeRpcContext } from 'devframe/client' +import type { EventEmitter } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { WhenContext } from 'devframe/utils/when' +import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding } from '../types/commands' +import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockUserEntry } from '../types/docks' +import type { DevframeDocksUserSettings } from '../types/settings' + +export type { DevframeClientRpcHost, RpcClientEvents } from 'devframe/client' + +export interface DockPanelStorage { + mode: 'float' | 'edge' + width: number + height: number + top: number + left: number + position: 'left' | 'right' | 'bottom' | 'top' + open: boolean + inactiveTimeout: number +} + +export type DockClientType = 'embedded' | 'standalone' + +export interface DocksContext extends DevframeRpcContext { + /** + * Type of the client environment + * + * 'embedded' - running inside an embedded floating panel + * 'standalone' - running inside a standalone window (no user app) + */ + readonly clientType: 'embedded' | 'standalone' + /** + * The panel context + */ + readonly panel: DocksPanelContext + /** + * The docks entries context + */ + readonly docks: DocksEntriesContext + /** + * The commands context for command palette and shortcuts + */ + readonly commands: CommandsContext + /** + * The when-clause context for conditional visibility + */ + readonly when: WhenClauseContext +} + +export interface WhenClauseContext { + /** + * Get the current when-clause context snapshot. + * Returns a reactive object with built-in variables and any custom plugin variables. + */ + readonly context: WhenContext +} + +export type DevframeClientContext = DocksContext + +export interface DocksPanelContext { + store: DockPanelStorage + isDragging: boolean + isResizing: boolean + readonly isVertical: boolean +} + +export interface DocksEntriesContext { + selectedId: string | null + readonly selected: DevframeDockEntry | null + entries: DevframeDockEntry[] + entryToStateMap: Map + groupedEntries: DevframeDockEntriesGrouped + settings: SharedState + /** + * Get the state of a dock entry by its ID + */ + getStateById: (id: string) => DockEntryState | undefined + /** + * Switch to the selected dock entry, pass `null` to clear the selection + * + * @returns Whether the selection was changed successfully + */ + switchEntry: (id?: string | null) => Promise + /** + * Toggle the selected dock entry + * + * @returns Whether the selection was changed successfully + */ + toggleEntry: (id: string) => Promise +} + +export interface DockEntryState { + entryMeta: DevframeDockEntry + readonly isActive: boolean + domElements: { + iframe?: HTMLIFrameElement | null + panel?: HTMLDivElement | null + } + events: EventEmitter +} + +export interface DockEntryStateEvents { + 'entry:activated': () => void + 'entry:deactivated': () => void + 'entry:updated': (newMeta: DevframeDockUserEntry) => void + 'dom:panel:mounted': (panel: HTMLDivElement) => void + 'dom:iframe:mounted': (iframe: HTMLIFrameElement) => void +} + +export interface CommandsContext { + /** + * All commands (server + client) + */ + readonly commands: DevframeCommandEntry[] + /** + * Palette-visible commands only (filtered by `showInPalette !== false`) + */ + readonly paletteCommands: DevframeCommandEntry[] + /** + * Register client-side command(s). Returns cleanup function. + */ + register: (cmd: DevframeClientCommand | DevframeClientCommand[]) => () => void + /** + * Execute a command by ID. Delegates to RPC for server commands. + */ + execute: (id: string, ...args: any[]) => Promise + /** + * Get effective keybindings for a command (defaults merged with overrides) + */ + getKeybindings: (id: string) => DevframeCommandKeybinding[] + /** + * User settings store (persisted, includes command shortcuts) + */ + settings: SharedState + /** + * Whether the command palette is open + */ + paletteOpen: boolean +} diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts new file mode 100644 index 0000000..475395f --- /dev/null +++ b/packages/hub/src/client/index.ts @@ -0,0 +1,5 @@ +export * from './client-script' +export * from './context' +export * from './docks' +export * from './remote' +export * from 'devframe/client' diff --git a/packages/hub/src/client/remote.ts b/packages/hub/src/client/remote.ts new file mode 100644 index 0000000..6897104 --- /dev/null +++ b/packages/hub/src/client/remote.ts @@ -0,0 +1,130 @@ +import type { DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' +import type { RemoteConnectionInfo } from '../types' +import { getDevframeRpcClient } from 'devframe/client' +import { REMOTE_CONNECTION_KEY } from 'devframe/constants' + +export type ConnectRemoteDevframeOptions = Omit + +function base64UrlDecode(value: string): string { + const padLen = (4 - value.length % 4) % 4 + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen) + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i) + return new TextDecoder().decode(bytes) +} + +function extractKeyFromFragment(hash: string): string | null { + if (!hash) + return null + const raw = hash.startsWith('#') ? hash.slice(1) : hash + const queryIdx = raw.indexOf('?') + if (queryIdx !== -1) { + const params = new URLSearchParams(raw.slice(queryIdx + 1)) + const value = params.get(REMOTE_CONNECTION_KEY) + if (value) + return value + } + + for (const part of raw.split('&')) { + const [k, v = ''] = part.split('=') + if (k === REMOTE_CONNECTION_KEY) + return decodeURIComponent(v) + } + return null +} + +function extractKeyFromQuery(search: string): string | null { + if (!search) + return null + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + return params.get(REMOTE_CONNECTION_KEY) +} + +/** + * Parse a {@link RemoteConnectionInfo} descriptor from the current page's URL + * (or a provided URL/string). Checks the URL fragment first, then the query. + * + * Returns `null` if no descriptor is present. + * Throws if the descriptor is malformed or its schema version is unsupported. + */ +export function parseRemoteConnection(input?: string): RemoteConnectionInfo | null { + let hash = '' + let search = '' + if (input === undefined) { + if (typeof location === 'undefined') + return null + hash = location.hash + search = location.search + } + else { + try { + const parsed = new URL(input, 'http://_') + hash = parsed.hash + search = parsed.search + } + catch { + // Treat as raw fragment or query string. + if (input.startsWith('#')) + hash = input + else if (input.startsWith('?')) + search = input + else + return null + } + } + + const encoded = extractKeyFromFragment(hash) ?? extractKeyFromQuery(search) + if (!encoded) + return null + + let payload: unknown + try { + payload = JSON.parse(base64UrlDecode(encoded)) + } + catch (cause) { + throw new Error('[@devframes/hub] Failed to decode remote connection descriptor.', { cause }) + } + + if (!payload || typeof payload !== 'object') + throw new Error('[@devframes/hub] Remote connection descriptor must be an object.') + + const info = payload as Partial + if (info.v !== 1) + throw new Error(`[@devframes/hub] Unsupported remote connection descriptor version: ${String(info.v)}`) + if (info.backend !== 'websocket' || typeof info.websocket !== 'string' || !info.websocket) + throw new Error('[@devframes/hub] Remote connection descriptor must carry a websocket URL.') + if (typeof info.authToken !== 'string' || !info.authToken) + throw new Error('[@devframes/hub] Remote connection descriptor must carry an auth token.') + if (typeof info.origin !== 'string') + throw new Error('[@devframes/hub] Remote connection descriptor must carry an origin.') + + return info as RemoteConnectionInfo +} + +/** + * One-liner for a hosted Devframe page: reads the connection descriptor from + * the current URL and returns a connected {@link DevframeRpcClient}. + * + * Pairs with `remote: true` on a `DevframeViewIframe` registered on the node + * side — the hub injects the descriptor into the iframe URL. + * + * @throws if no descriptor is present in the URL. + */ +export async function connectRemoteDevframe( + options: ConnectRemoteDevframeOptions = {}, +): Promise { + const info = parseRemoteConnection() + if (!info) { + throw new Error( + `[@devframes/hub] No remote connection descriptor found in the URL. ` + + `Open this page through a hub-registered dock with \`remote: true\`.`, + ) + } + return getDevframeRpcClient({ + ...options, + connectionMeta: info, + authToken: info.authToken, + }) +} diff --git a/packages/hub/src/constants.ts b/packages/hub/src/constants.ts new file mode 100644 index 0000000..474884b --- /dev/null +++ b/packages/hub/src/constants.ts @@ -0,0 +1,22 @@ +import type { DevframeDocksUserSettings } from './types/settings' + +export * from 'devframe/constants' + +export const DEFAULT_CATEGORIES_ORDER: Record = { + 'default': 0, + 'app': 100, + 'framework': 200, + 'web': 300, + 'advanced': 400, + '~builtin': 1000, +} + +export const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings = () => ({ + docksHidden: [], + docksCategoriesHidden: [], + docksPinned: [], + docksCustomOrder: {}, + showIframeAddressBar: false, + closeOnOutsideClick: false, + commandShortcuts: {}, +}) diff --git a/packages/hub/src/define.ts b/packages/hub/src/define.ts new file mode 100644 index 0000000..e69b87b --- /dev/null +++ b/packages/hub/src/define.ts @@ -0,0 +1,27 @@ +import type { WhenContext, WhenExpression } from 'devframe/utils/when' +import type { DevframeHubContext } from './node/context' +import type { DevframeServerCommandInput } from './types/commands' +import type { DevframeDockUserEntry } from './types/docks' +import type { JsonRenderSpec } from './types/json-render' +import { createDefineWrapperWithContext } from 'devframe/rpc' + +export const defineHubRpcFunction = createDefineWrapperWithContext() + +export function defineCommand( + command: Omit & { when?: WhenExpression }, +): DevframeServerCommandInput { + return command as DevframeServerCommandInput +} + +export function defineDockEntry< + const T extends DevframeDockUserEntry, + const W extends string = '', +>( + entry: Omit & { when?: WhenExpression }, +): T { + return entry as unknown as T +} + +export function defineJsonRenderSpec(spec: JsonRenderSpec): JsonRenderSpec { + return spec +} diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts new file mode 100644 index 0000000..fca59c2 --- /dev/null +++ b/packages/hub/src/index.ts @@ -0,0 +1,2 @@ +export * from './define' +export type * from './types' diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts new file mode 100644 index 0000000..f8d2c32 --- /dev/null +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHostContext, startHttpAndWs } from 'devframe/node' +import { getInternalContext } from 'devframe/node/hub-internals' +import { describe, expect, it } from 'vitest' +import { createHubContext } from '../context' + +function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost:5173', + getStorageDir: () => storageDir, + } +} + +describe('createHubContext shared state', () => { + it('seeds built-in docks immediately', async () => { + const context = await createHubContext({ + cwd: process.cwd(), + mode: 'build', + host: createHost(), + }) + + const docks = await context.rpc.sharedState.get('devframe:docks') + expect(docks.value().map(dock => dock.id)).toEqual([ + '~terminals', + '~messages', + '~settings', + ]) + }) +}) + +describe('startHttpAndWs remote endpoint metadata', () => { + it('sets and clears the internal websocket endpoint', async () => { + const context = await createHostContext({ + cwd: process.cwd(), + mode: 'dev', + host: createHost(), + }) + + const started = await startHttpAndWs({ + context, + host: '127.0.0.1', + port: 0, + }) + + expect(getInternalContext(context).wsEndpoint).toEqual({ + url: `ws://127.0.0.1:${started.port}`, + }) + + await started.close() + expect(getInternalContext(context).wsEndpoint).toBeUndefined() + }) +}) diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts new file mode 100644 index 0000000..7f9f5da --- /dev/null +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -0,0 +1,63 @@ +import type { DevframeHubContext } from '../context' +import { describe, expect, it } from 'vitest' +import { DevframeCommandsHost } from '../host-commands' + +describe('devframeCommandsHost command id validation', () => { + it('rejects duplicate ids inside one command tree', () => { + const host = new DevframeCommandsHost({} as DevframeHubContext) + + expect(() => host.register({ + id: 'tool:parent', + title: 'Parent', + children: [ + { id: 'tool:child', title: 'Child' }, + { id: 'tool:child', title: 'Duplicate child' }, + ], + })).toThrow('Command id "tool:child" is already used') + }) + + it('rejects child ids that collide with existing command trees', () => { + const host = new DevframeCommandsHost({} as DevframeHubContext) + host.register({ + id: 'tool:parent', + title: 'Parent', + children: [ + { id: 'tool:child', title: 'Child' }, + ], + }) + + expect(() => host.register({ + id: 'other:parent', + title: 'Other parent', + children: [ + { id: 'tool:child', title: 'Duplicate child' }, + ], + })).toThrow('Command id "tool:child" is already used') + + expect(() => host.register({ + id: 'tool:child', + title: 'Top-level collision', + })).toThrow('Command id "tool:child" is already used') + }) + + it('validates updated children against other command trees', () => { + const host = new DevframeCommandsHost({} as DevframeHubContext) + host.register({ + id: 'other:parent', + title: 'Other parent', + children: [ + { id: 'other:child', title: 'Other child' }, + ], + }) + const handle = host.register({ + id: 'tool:parent', + title: 'Parent', + }) + + expect(() => handle.update({ + children: [ + { id: 'other:child', title: 'Duplicate child' }, + ], + })).toThrow('Command id "other:child" is already used') + }) +}) diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts new file mode 100644 index 0000000..f825e1a --- /dev/null +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -0,0 +1,78 @@ +import type { DevframeHubContext } from '../context' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { REMOTE_CONNECTION_KEY } from 'devframe/constants' +import { getInternalContext } from 'devframe/node/hub-internals' +import { describe, expect, it } from 'vitest' +import { parseRemoteConnection } from '../../client/remote' +import { DevframeDocksHost } from '../host-docks' + +function createContext(): DevframeHubContext { + const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-docks-')) + return { + host: { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost:5173', + getStorageDir: () => storageDir, + }, + } as unknown as DevframeHubContext +} + +describe('devframeDockHost remote URL enrichment', () => { + it('preserves hash routes and replaces existing remote descriptors', () => { + const context = createContext() + getInternalContext(context).wsEndpoint = { url: 'ws://localhost:4173' } + const host = new DevframeDocksHost(context) + + host.register({ + type: 'iframe', + id: 'remote', + title: 'Remote', + url: 'https://remote.test/app#/inspect?tab=state', + remote: true, + }) + + const first = host.values({ includeBuiltin: false })[0] + expect(first.type).toBe('iframe') + const firstUrl = first.type === 'iframe' ? first.url : '' + expect(firstUrl).toContain(`#/inspect?tab=state&${REMOTE_CONNECTION_KEY}=`) + expect(parseRemoteConnection(firstUrl)).toMatchObject({ + backend: 'websocket', + websocket: 'ws://localhost:4173', + origin: 'http://localhost:5173', + }) + + host.update({ + type: 'iframe', + id: 'remote', + title: 'Remote', + url: firstUrl, + remote: true, + }) + + const second = host.values({ includeBuiltin: false })[0] + const secondUrl = second.type === 'iframe' ? second.url : '' + expect(secondUrl.match(new RegExp(REMOTE_CONNECTION_KEY, 'g'))).toHaveLength(1) + expect(secondUrl).toContain('#/inspect?tab=state&') + }) + + it('preserves non-route fragments with the ampersand descriptor form', () => { + const context = createContext() + getInternalContext(context).wsEndpoint = { url: 'ws://localhost:4173' } + const host = new DevframeDocksHost(context) + + host.register({ + type: 'iframe', + id: 'remote', + title: 'Remote', + url: 'https://remote.test/app#section', + remote: true, + }) + + const entry = host.values({ includeBuiltin: false })[0] + const url = entry.type === 'iframe' ? entry.url : '' + expect(url).toContain(`#section&${REMOTE_CONNECTION_KEY}=`) + expect(parseRemoteConnection(url)?.websocket).toBe('ws://localhost:4173') + }) +}) diff --git a/packages/hub/src/node/__tests__/host-messages.test.ts b/packages/hub/src/node/__tests__/host-messages.test.ts new file mode 100644 index 0000000..903de4b --- /dev/null +++ b/packages/hub/src/node/__tests__/host-messages.test.ts @@ -0,0 +1,23 @@ +import type { DevframeHubContext } from '../context' +import { describe, expect, it } from 'vitest' +import { DevframeMessagesHost } from '../host-messages' + +describe('devframeMessagesHost', () => { + it('caps removal history', async () => { + const host = new DevframeMessagesHost({} as DevframeHubContext) + + for (let i = 0; i < 1005; i++) { + const id = `message:${i}` + await host.add({ + id, + level: 'info', + message: id, + }) + await host.remove(id) + } + + expect(host.removals).toHaveLength(1000) + expect(host.removals[0].id).toBe('message:5') + expect(host.removals.at(-1)?.id).toBe('message:1004') + }) +}) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts new file mode 100644 index 0000000..c32a3f9 --- /dev/null +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -0,0 +1,134 @@ +import type { DevframeTerminalSession } from '../../types/terminals' +import type { DevframeHubContext } from '../context' +import process from 'node:process' +import { describe, expect, it, vi } from 'vitest' +import { DevframeTerminalsHost } from '../host-terminals' + +interface FakeSink { + write: ReturnType + close: ReturnType + error: ReturnType + readonly closed: boolean +} + +function createTerminalHost() { + const sinks = new Map() + const context = { + rpc: { + streaming: { + create: () => ({ + start: ({ id }: { id: string }) => { + let closed = false + const sink: FakeSink = { + write: vi.fn(), + close: vi.fn(() => { + closed = true + }), + error: vi.fn(() => { + closed = true + }), + get closed() { + return closed + }, + } + sinks.set(id, sink) + return sink + }, + }), + }, + }, + } as unknown as DevframeHubContext + + return { + host: new DevframeTerminalsHost(context), + sinks, + } +} + +async function waitUntil(assertion: () => void): Promise { + const deadline = Date.now() + 1000 + let lastError: unknown + while (Date.now() < deadline) { + try { + assertion() + return + } + catch (error) { + lastError = error + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + throw lastError +} + +describe('devframeTerminalHost stream lifecycle', () => { + it('cancels a bound stream when a session is removed', async () => { + const { host, sinks } = createTerminalHost() + let controller: ReadableStreamDefaultController + let cancelled = false + const stream = new ReadableStream({ + start(_controller) { + controller = _controller + }, + cancel() { + cancelled = true + }, + }) + const session: DevframeTerminalSession = { + id: 'terminal', + title: 'Terminal', + status: 'running', + stream, + } + + host.register(session) + controller!.enqueue('hello') + + await waitUntil(() => { + expect(session.buffer).toEqual(['hello']) + }) + + host.remove(session) + + await waitUntil(() => { + expect(cancelled).toBe(true) + }) + expect(sinks.get('terminal')?.closed).toBe(true) + }) + + it('closes child-process streams after natural process exit', async () => { + const { host, sinks } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'process.stdout.write("done")'], + }, { + id: 'child', + title: 'Child', + }) + + await waitUntil(() => { + expect(sinks.get('child')?.closed).toBe(true) + }) + expect(session.buffer?.join('')).toContain('done') + }) + + it('closes child-process streams on terminate', async () => { + const { host, sinks } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + }, { + id: 'child', + title: 'Child', + }) + + await session.terminate() + + await waitUntil(() => { + expect(sinks.get('child')?.closed).toBe(true) + }) + expect(session.getChildProcess()).toBeUndefined() + }) +}) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts new file mode 100644 index 0000000..ec9bf5d --- /dev/null +++ b/packages/hub/src/node/context.ts @@ -0,0 +1,131 @@ +import type { CreateHostContextOptions } from 'devframe/node' +import type { DevframeHost, DevframeNodeContext } from 'devframe/types' +import type { DevframeCommandsHost } from '../types/commands' +import type { DevframeDocksHost } from '../types/docks' +import type { JsonRenderer, JsonRenderSpec } from '../types/json-render' +import type { DevframeMessagesHost } from '../types/messages' +import type { DevframeTerminalsHost } from '../types/terminals' +import { createHostContext } from 'devframe/node' +import { debounce } from 'perfect-debounce' +import { DevframeCommandsHost as CommandsHostImpl } from './host-commands' +import { DevframeDocksHost as DocksHostImpl } from './host-docks' +import { DevframeMessagesHost as MessagesHostImpl } from './host-messages' +import { DevframeTerminalsHost as TerminalsHostImpl } from './host-terminals' +import { builtinHubRpcDeclarations } from './rpc-builtins' + +/** + * Hub-augmented node context — extends devframe's framework-neutral + * `DevframeNodeContext` with the hub-level subsystems (`docks`, + * `terminals`, `messages`, `commands`) and the `createJsonRenderer` + * factory. + * + * Framework kits further extend this with their own slots (e.g. + * `viteConfig`, `viteServer`). Host-specific capabilities (editor open, + * filesystem reveal, etc.) ship as kit-registered RPC functions rather + * than as part of this surface. + */ +export interface DevframeHubContext extends DevframeNodeContext { + readonly host: DevframeHost + docks: DevframeDocksHost + terminals: DevframeTerminalsHost + messages: DevframeMessagesHost + commands: DevframeCommandsHost + /** + * Create a JsonRenderer handle for building json-render powered UIs. + */ + createJsonRenderer: (spec: JsonRenderSpec) => JsonRenderer +} + +export interface CreateHubContextOptions extends CreateHostContextOptions {} + +/** + * Create a hub-level node context: wraps devframe's `createHostContext`, + * attaches the hub hosts (`docks`, `terminals`, `messages`, `commands`), + * registers the hub's built-in RPC commands, and wires the shared-state + * synchronization that powers a hub-aware client UI. + */ +export async function createHubContext(options: CreateHubContextOptions): Promise { + const baseContext = await createHostContext({ + ...options, + builtinRpcDeclarations: [ + ...builtinHubRpcDeclarations, + ...(options.builtinRpcDeclarations ?? []), + ], + }) + const context = baseContext as DevframeHubContext + + const docks = new DocksHostImpl(context) + const terminals = new TerminalsHostImpl(context) + const messages = new MessagesHostImpl(context) + const commands = new CommandsHostImpl(context) + + context.docks = docks + context.terminals = terminals + context.messages = messages + context.commands = commands + + await docks.init() + + let jrCounter = 0 + context.createJsonRenderer = (initialSpec: JsonRenderSpec): JsonRenderer => { + const stateKey = `devframe:json-render:${jrCounter++}` + const statePromise = context.rpc.sharedState.get(stateKey as any, { + initialValue: initialSpec as any, + }) + + return { + _stateKey: stateKey, + async updateSpec(spec) { + const state = await statePromise + state.mutate(() => spec as any) + }, + async updateState(newState) { + const state = await statePromise + state.mutate((draft: any) => { + draft.state = { ...draft.state, ...newState } + }) + }, + } + } + + const debounceMs = options.mode === 'build' ? 0 : 10 + + const docksSharedState = await context.rpc.sharedState.get('devframe:docks', { initialValue: [] }) + const refreshDocks = debounce(() => { + docksSharedState.mutate(() => docks.values()) + }, debounceMs) + docks.events.on('dock:entry:updated', refreshDocks) + docksSharedState.mutate(() => docks.values()) + + const broadcastTerminals = debounce(() => { + context.rpc.broadcast({ + method: 'devframe:terminals:updated', + args: [], + }) + docksSharedState.mutate(() => docks.values()) + }, debounceMs) + terminals.events.on('terminal:session:updated', broadcastTerminals) + + const broadcastMessages = debounce(() => { + context.rpc.broadcast({ + method: 'devframe:messages:updated', + args: [], + }) + docksSharedState.mutate(() => docks.values()) + }, debounceMs) + messages.events.on('message:added', broadcastMessages) + messages.events.on('message:updated', broadcastMessages) + messages.events.on('message:removed', broadcastMessages) + messages.events.on('message:cleared', broadcastMessages) + + const commandsSharedState = await context.rpc.sharedState.get('devframe:commands', { initialValue: [] }) + const syncCommands = debounce(() => { + commandsSharedState.mutate(() => commands.list()) + }, debounceMs) + commands.events.on('command:registered', syncCommands) + commands.events.on('command:unregistered', syncCommands) + + commandsSharedState.mutate(() => commands.list()) + + return context +} diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts new file mode 100644 index 0000000..7bb704a --- /dev/null +++ b/packages/hub/src/node/diagnostics.ts @@ -0,0 +1,47 @@ +import { defineDiagnostics } from 'nostics' +import { hubReporter } from '../utils/diagnostics-reporter' + +// Hub-side diagnostics for docks, terminals, messages, and commands. +// Shares the `DF` prefix with devframe core; the hub reserves the +// `DF8xxx` range so the unified surface stays collision-free. +// Sub-ranges: +// DF8000-DF8099 — hub context / lifecycle +// DF8100-DF8199 — docks +// DF8200-DF8299 — terminals +// DF8300-DF8399 — messages +// DF8400-DF8499 — commands +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + reporters: [hubReporter], + codes: { + DF8100: { + why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`, + fix: 'Use the `force` parameter to overwrite an existing registration.', + }, + DF8101: { + why: 'Cannot change the id of a dock. Use register() to add new docks.', + }, + DF8102: { + why: (p: { id: string }) => `Dock with id "${p.id}" is not registered. Use register() to add new docks.`, + }, + DF8200: { + why: (p: { id: string }) => `Terminal session with id "${p.id}" already registered`, + }, + DF8201: { + why: (p: { id: string }) => `Terminal session with id "${p.id}" not registered`, + }, + DF8400: { + why: (p: { id: string }) => `Command "${p.id}" is already registered`, + }, + DF8401: { + why: 'Cannot change the id of a command. Use register() to add new commands.', + }, + DF8402: { + why: (p: { id: string }) => `Command "${p.id}" is not registered`, + }, + DF8403: { + why: (p: { id: string }) => `Command id "${p.id}" is already used by another command or child command`, + fix: 'Use globally unique command ids for top-level commands and all child commands.', + }, + }, +}) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts new file mode 100644 index 0000000..2668f39 --- /dev/null +++ b/packages/hub/src/node/host-commands.ts @@ -0,0 +1,142 @@ +import type { + DevframeCommandHandle, + DevframeCommandsHost as DevframeCommandsHostType, + DevframeServerCommandEntry, + DevframeServerCommandInput, +} from '../types/commands' +import type { DevframeHubContext } from './context' +import { createEventEmitter } from 'devframe/utils/events' +import { diagnostics } from './diagnostics' + +function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined { + for (const child of command.children ?? []) { + if (child.id === id) + return child + const nested = findChildCommand(child, id) + if (nested) + return nested + } + return undefined +} + +function collectCommandIds(command: DevframeServerCommandInput, ids: string[] = []): string[] { + ids.push(command.id) + for (const child of command.children ?? []) + collectCommandIds(child, ids) + return ids +} + +function validateCommandIds( + commands: Map, + command: DevframeServerCommandInput, + ignoreTopLevelId?: string, +): void { + const ids = collectCommandIds(command) + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) + throw diagnostics.DF8403({ id }) + seen.add(id) + } + + for (const [registeredId, registered] of commands) { + if (registeredId === ignoreTopLevelId) + continue + const registeredIds = new Set(collectCommandIds(registered)) + for (const id of ids) { + if (registeredIds.has(id)) + throw diagnostics.DF8403({ id }) + } + } +} + +export class DevframeCommandsHost implements DevframeCommandsHostType { + public readonly commands: DevframeCommandsHostType['commands'] = new Map() + public readonly events: DevframeCommandsHostType['events'] = createEventEmitter() + + constructor( + public readonly context: DevframeHubContext, + ) {} + + register(command: DevframeServerCommandInput): DevframeCommandHandle { + if (this.commands.has(command.id)) { + throw diagnostics.DF8400({ id: command.id }) + } + validateCommandIds(this.commands, command) + this.commands.set(command.id, command) + this.events.emit('command:registered', this.toSerializable(command)) + + return { + id: command.id, + update: (patch: Partial>) => { + if ('id' in patch) { + throw diagnostics.DF8401() + } + const existing = this.commands.get(command.id) + if (!existing) { + throw diagnostics.DF8402({ id: command.id }) + } + const next = { + ...existing, + ...patch, + id: existing.id, + } + validateCommandIds(this.commands, next, existing.id) + Object.assign(existing, patch) + this.events.emit('command:registered', this.toSerializable(existing)) + }, + unregister: () => this.unregister(command.id), + } + } + + unregister(id: string): boolean { + const deleted = this.commands.delete(id) + if (deleted) { + this.events.emit('command:unregistered', id) + } + return deleted + } + + async execute(id: string, ...args: any[]): Promise { + const found = this.findCommand(id) + if (!found) { + throw diagnostics.DF8402({ id }) + } + if (!found.handler) { + throw new Error(`Command "${id}" has no handler (group-only command)`) + } + return found.handler(...args) + } + + list(): DevframeServerCommandEntry[] { + return Array.from(this.commands.values()).map(cmd => this.toSerializable(cmd)) + } + + private findCommand(id: string): DevframeServerCommandInput | undefined { + // Check top-level + const topLevel = this.commands.get(id) + if (topLevel) + return topLevel + + // Search children + for (const cmd of this.commands.values()) { + const child = findChildCommand(cmd, id) + if (child) + return child + } + + return undefined + } + + private toSerializable(cmd: DevframeServerCommandInput): DevframeServerCommandEntry { + const { handler: _, children, ...rest } = cmd + return { + ...rest, + source: 'server', + ...(children + ? { children: children.map((c: DevframeServerCommandInput) => this.toSerializable(c)) } + : {} + ), + } + } +} diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts new file mode 100644 index 0000000..2a6924c --- /dev/null +++ b/packages/hub/src/node/host-docks.ts @@ -0,0 +1,229 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { + DevframeDockEntry, + DevframeDocksHost as DevframeDocksHostType, + DevframeDockUserEntry, + DevframeViewBuiltin, + DevframeViewIframe, + RemoteConnectionInfo, + RemoteDockOptions, +} from '../types/docks' +import type { DevframeDocksUserSettings } from '../types/settings' +import type { DevframeHubContext } from './context' +import { REMOTE_CONNECTION_KEY } from 'devframe/constants' +import { createStorage } from 'devframe/node' +import { getInternalContext } from 'devframe/node/hub-internals' +import { createEventEmitter } from 'devframe/utils/events' +import { join } from 'pathe' +import { DEFAULT_STATE_USER_SETTINGS } from '../constants' +import { diagnostics } from './diagnostics' + +interface RemoteDockRecord { + token: string + options: Required +} + +function normaliseRemoteOptions(remote: true | RemoteDockOptions): Required { + const opts = remote === true ? {} : remote + return { + transport: opts.transport ?? 'fragment', + originLock: opts.originLock ?? true, + } +} + +function base64UrlEncode(value: string): string { + // URL-safe base64 without padding so the descriptor is compact and safe to + // drop into a URL without escaping. + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) + binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function buildRemoteUrl(baseUrl: string, payload: RemoteConnectionInfo, transport: 'fragment' | 'query'): string { + const encoded = base64UrlEncode(JSON.stringify(payload)) + const param = `${REMOTE_CONNECTION_KEY}=${encoded}` + if (transport === 'fragment') { + // Replace any existing fragment/query descriptor bearing our key; otherwise append. + const hashIdx = baseUrl.indexOf('#') + if (hashIdx === -1) + return `${baseUrl}#${param}` + const before = baseUrl.slice(0, hashIdx) + const rawHash = baseUrl.slice(hashIdx + 1) + if (!rawHash) + return `${before}#${param}` + const routeQueryIdx = rawHash.indexOf('?') + if (routeQueryIdx !== -1) { + const beforeQuery = rawHash.slice(0, routeQueryIdx + 1) + const params = new URLSearchParams(rawHash.slice(routeQueryIdx + 1)) + params.set(REMOTE_CONNECTION_KEY, encoded) + return `${before}#${beforeQuery}${params.toString()}` + } + + const parts = rawHash.split('&') + const existingIdx = parts.findIndex((part) => { + const [key] = part.split('=') + return key === REMOTE_CONNECTION_KEY + }) + if (existingIdx >= 0) { + parts[existingIdx] = param + return `${before}#${parts.join('&')}` + } + return `${before}#${rawHash}&${param}` + } + // query + const qIdx = baseUrl.indexOf('?') + const hashIdx = baseUrl.indexOf('#') + const hash = hashIdx === -1 ? '' : baseUrl.slice(hashIdx) + const beforeHash = hashIdx === -1 ? baseUrl : baseUrl.slice(0, hashIdx) + const sep = qIdx === -1 || qIdx >= (hashIdx === -1 ? beforeHash.length : hashIdx) ? '?' : '&' + return `${beforeHash}${sep}${param}${hash}` +} + +export class DevframeDocksHost implements DevframeDocksHostType { + public readonly views: DevframeDocksHostType['views'] = new Map() + public readonly events: DevframeDocksHostType['events'] = createEventEmitter() + public userSettings: SharedState = undefined! + + /** Dock-id → allocated remote token + resolved options. */ + private readonly remoteDocks = new Map() + + constructor( + public readonly context: DevframeHubContext, + ) { + + } + + async init() { + this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { + sharedState: createStorage({ + filepath: join(this.context.host.getStorageDir('workspace'), 'settings.json'), + initialValue: DEFAULT_STATE_USER_SETTINGS(), + }), + }) + } + + values({ + includeBuiltin = true, + }: { + includeBuiltin?: boolean + } = {}): DevframeDockEntry[] { + const context = this.context + const builtinDocksEntries: DevframeViewBuiltin[] = [ + { + type: '~builtin', + id: '~terminals', + title: 'Terminals', + icon: 'ph:terminal-duotone', + category: '~builtin', + get when() { + return context.terminals.sessions.size === 0 ? 'false' : undefined + }, + }, + { + type: '~builtin', + id: '~messages', + title: 'Messages & Notifications', + icon: 'ph:notification-duotone', + category: '~builtin', + get badge() { + const size = context.messages.entries.size + return size > 0 ? String(size) : undefined + }, + }, + { + type: '~builtin', + id: '~settings', + title: 'Settings', + category: '~builtin', + icon: 'ph:gear-duotone', + }, + ] + + return [ + ...Array.from(this.views.values(), view => this.projectView(view)), + ...(includeBuiltin ? builtinDocksEntries : []), + ] + } + + private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry { + if (view.type !== 'iframe' || !view.remote) + return view + const record = this.remoteDocks.get(view.id) + const endpoint = getInternalContext(this.context as DevframeNodeContext).wsEndpoint + if (!record || !endpoint) + return view + + const payload: RemoteConnectionInfo = { + v: 1, + backend: 'websocket', + websocket: endpoint.url, + authToken: record.token, + origin: this.resolveDevServerOrigin(), + } + return { + ...view, + url: buildRemoteUrl(view.url, payload, record.options.transport), + } satisfies DevframeViewIframe + } + + private resolveDevServerOrigin(): string { + return this.context.host.resolveOrigin() + } + + register(view: T, force?: boolean): { + update: (patch: Partial) => void + } { + if (this.views.has(view.id) && !force) { + throw diagnostics.DF8100({ id: view.id }) + } + this.prepareRemoteRegistration(view) + this.views.set(view.id, view) + this.events.emit('dock:entry:updated', view) + + return { + update: (patch) => { + if (patch.id && patch.id !== view.id) { + throw diagnostics.DF8101() + } + this.update(Object.assign(this.views.get(view.id)!, patch)) + }, + } + } + + update(view: DevframeDockUserEntry): void { + if (!this.views.has(view.id)) { + throw diagnostics.DF8102({ id: view.id }) + } + this.prepareRemoteRegistration(view) + this.views.set(view.id, view) + this.events.emit('dock:entry:updated', view) + } + + private prepareRemoteRegistration(view: DevframeDockUserEntry): void { + const internal = getInternalContext(this.context as DevframeNodeContext) + // Always revoke any previously allocated token for this dock id — covers + // force re-registration and update() paths. + internal.revokeRemoteTokensForDock(view.id) + this.remoteDocks.delete(view.id) + + if (view.type !== 'iframe' || !view.remote) + return + + const options = normaliseRemoteOptions(view.remote) + let dockOrigin: string + try { + dockOrigin = new URL(view.url).origin + } + catch { + // Relative/invalid URL — origin-lock can't be enforced. Fall back to the + // dev-server origin; this still works because the iframe loads in the + // same browser anyway. + dockOrigin = this.resolveDevServerOrigin() + } + const token = internal.allocateRemoteToken(view.id, dockOrigin, options.originLock) + this.remoteDocks.set(view.id, { token, options }) + } +} diff --git a/packages/hub/src/node/host-messages.ts b/packages/hub/src/node/host-messages.ts new file mode 100644 index 0000000..592db29 --- /dev/null +++ b/packages/hub/src/node/host-messages.ts @@ -0,0 +1,145 @@ +import type { + DevframeMessageEntry, + DevframeMessageEntryInput, + DevframeMessageHandle, + DevframeMessagesHost as DevframeMessagesHostType, +} from '../types/messages' +import type { DevframeHubContext } from './context' +import { createEventEmitter } from 'devframe/utils/events' +import { nanoid } from 'devframe/utils/nanoid' + +const MAX_ENTRIES = 1000 +const MAX_REMOVALS = 1000 + +function recordRemoval( + removals: Array<{ id: string, time: number }>, + id: string, + time: number, +): void { + removals.push({ id, time }) + if (removals.length > MAX_REMOVALS) + removals.splice(0, removals.length - MAX_REMOVALS) +} + +export class DevframeMessagesHost implements DevframeMessagesHostType { + public readonly entries: DevframeMessagesHostType['entries'] = new Map() + public readonly events: DevframeMessagesHostType['events'] = createEventEmitter() + + /** Tracks when each entry was last added or updated (monotonic) */ + readonly lastModified = new Map() + /** Tracks recently removed entry IDs with their removal time */ + readonly removals: Array<{ id: string, time: number }> = [] + + private _autoDeleteTimers = new Map>() + private _clock = 0 + + private _tick(): number { + return ++this._clock + } + + constructor( + public readonly context: DevframeHubContext, + ) {} + + async add(input: DevframeMessageEntryInput): Promise { + // Dedupe: if an entry with the same explicit id exists, update it instead + if (input.id && this.entries.has(input.id)) { + await this.update(input.id, input) + return this._createHandle(input.id) + } + + const entry: DevframeMessageEntry = { + ...input, + id: input.id ?? nanoid(), + timestamp: input.timestamp ?? Date.now(), + from: (input as any).from ?? 'server', + } + + // FIFO eviction when at capacity + if (this.entries.size >= MAX_ENTRIES) { + const oldest = this.entries.keys().next().value! + await this.remove(oldest) + } + + this.entries.set(entry.id, entry) + this.lastModified.set(entry.id, this._tick()) + this.events.emit('message:added', entry) + + if (entry.autoDelete) { + this._autoDeleteTimers.set(entry.id, setTimeout(() => { + this.remove(entry.id) + }, entry.autoDelete)) + } + + return this._createHandle(entry.id) + } + + async update(id: string, patch: Partial): Promise { + const existing = this.entries.get(id) + if (!existing) + return undefined + + const updated: DevframeMessageEntry = { + ...existing, + ...patch, + id: existing.id, + from: existing.from, + timestamp: existing.timestamp, + } + + this.entries.set(id, updated) + this.lastModified.set(id, this._tick()) + this.events.emit('message:updated', updated) + + // Reset autoDelete timer if changed + if (patch.autoDelete !== undefined) { + const timer = this._autoDeleteTimers.get(id) + if (timer) { + clearTimeout(timer) + this._autoDeleteTimers.delete(id) + } + if (patch.autoDelete) { + this._autoDeleteTimers.set(id, setTimeout(() => { + this.remove(id) + }, patch.autoDelete)) + } + } + + return updated + } + + async remove(id: string): Promise { + const timer = this._autoDeleteTimers.get(id) + if (timer) { + clearTimeout(timer) + this._autoDeleteTimers.delete(id) + } + this.entries.delete(id) + this.lastModified.delete(id) + recordRemoval(this.removals, id, this._tick()) + this.events.emit('message:removed', id) + } + + async clear(): Promise { + for (const timer of this._autoDeleteTimers.values()) + clearTimeout(timer) + this._autoDeleteTimers.clear() + const tick = this._tick() + for (const id of this.entries.keys()) + recordRemoval(this.removals, id, tick) + this.entries.clear() + this.lastModified.clear() + this.events.emit('message:cleared') + } + + private _createHandle(id: string): DevframeMessageHandle { + // eslint-disable-next-line ts/no-this-alias + const host = this + return { + get entry() { return host.entries.get(id)! }, + get id() { return id }, + update: patch => host.update(id, patch), + dismiss: () => host.remove(id), + } + } +} diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts new file mode 100644 index 0000000..1ac8ceb --- /dev/null +++ b/packages/hub/src/node/host-terminals.ts @@ -0,0 +1,263 @@ +import type { RpcStreamingChannel } from 'devframe/types' +import type { Result as TinyExecResult } from 'tinyexec' +import type { + DevframeChildProcessExecuteOptions, + DevframeChildProcessTerminalSession, + DevframeTerminalSession, + DevframeTerminalSessionBase, + DevframeTerminalsHost as DevframeTerminalsHostType, +} from '../types/terminals' +import type { DevframeHubContext } from './context' +import process from 'node:process' +import { createEventEmitter } from 'devframe/utils/events' +import { diagnostics } from './diagnostics' + +type PartialWithoutId = Partial & { id: string } + +/** + * Channel name used for terminal stream output. Stable, well-known so + * hub-aware clients can subscribe by name. + */ +const TERMINAL_STREAM_CHANNEL = 'devframe:terminals' as const +const TERMINAL_REPLAY_WINDOW = 1000 + +export class DevframeTerminalsHost implements DevframeTerminalsHostType { + public readonly sessions: DevframeTerminalsHostType['sessions'] = new Map() + public readonly events: DevframeTerminalsHostType['events'] = createEventEmitter() + + private _boundStreams = new Map void + stream: ReadableStream + }>() + + private _channel?: RpcStreamingChannel + + constructor( + public readonly context: DevframeHubContext, + ) { + } + + /** + * 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: DevframeTerminalSession): DevframeTerminalSession { + if (this.sessions.has(session.id)) { + throw diagnostics.DF8200({ id: session.id }) + } + this.sessions.set(session.id, session) + this.bindStream(session) + this.events.emit('terminal:session:updated', session) + return session + } + + update(patch: PartialWithoutId): void { + if (!this.sessions.has(patch.id)) { + throw diagnostics.DF8201({ id: patch.id }) + } + const session = this.sessions.get(patch.id)! + Object.assign(session, patch) + this.sessions.set(patch.id, session) + this.bindStream(session) + this.events.emit('terminal:session:updated', session) + } + + remove(session: DevframeTerminalSession): void { + this._boundStreams.get(session.id)?.dispose() + this.sessions.delete(session.id) + this.events.emit('terminal:session:updated', session) + this._boundStreams.delete(session.id) + } + + private bindStream(session: DevframeTerminalSession) { + // Skip when the same stream is already bound + if (this._boundStreams.has(session.id) && this._boundStreams.get(session.id)?.stream === session.stream) + return + + // Dispose the previous stream + this._boundStreams.get(session.id)?.dispose() + this._boundStreams.delete(session.id) + + // If new stream is not available, skip + if (!session.stream) + return + + session.buffer ||= [] + 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 + // `devframe:terminals:list`. + const sink = channel?.start({ id: session.id }) + + const reader = session.stream.getReader() + let disposed = false + ;(async () => { + try { + while (true) { + if (disposed) + break + const result = await reader.read() + if (disposed) + break + if (result.done) + break + // Mirror to the legacy session.buffer used by `terminals:read` — + // unbounded history kept for the snapshot endpoint. + sessionBuffer.push(result.value) + sink?.write(result.value) + } + if (!disposed && sink && !sink.closed) + sink.close() + } + catch (error) { + if (!disposed && sink && !sink.closed) + sink.error(error) + } + finally { + try { + reader.releaseLock() + } + catch { + // Already released by the stream implementation. + } + } + })() + this._boundStreams.set(session.id, { + dispose: () => { + disposed = true + reader.cancel('terminal stream disposed').catch(() => {}) + if (sink && !sink.closed) + sink.close() + }, + stream: session.stream, + }) + } + + async startChildProcess( + executeOptions: DevframeChildProcessExecuteOptions, + terminal: Omit, + ): Promise { + if (this.sessions.has(terminal.id)) { + throw diagnostics.DF8200({ id: terminal.id }) + } + const { exec } = await import('tinyexec') + + let controller: ReadableStreamDefaultController | undefined + let cp: TinyExecResult | undefined + let runId = 0 + let streamClosed = false + + const closeStream = () => { + if (streamClosed) + return + streamClosed = true + try { + controller?.close() + } + catch { + // The stream may already be closed by cancellation. + } + } + + const errorStream = (error: unknown) => { + if (streamClosed) + return + streamClosed = true + try { + controller?.error(error) + } + catch { + // The stream may already be closed by cancellation. + } + } + + const stream = new ReadableStream({ + start(_controller) { + controller = _controller + }, + cancel() { + cp?.kill() + cp = undefined + closeStream() + }, + }) + + function createChildProcess() { + const currentRun = ++runId + const cp = exec( + executeOptions.command, + executeOptions.args || [], + { + nodeOptions: { + env: { + COLORS: 'true', + FORCE_COLOR: 'true', + ...(executeOptions.env || {}), + }, + cwd: executeOptions.cwd ?? process.cwd(), + stdio: 'pipe', + }, + }, + ) + + ;(async () => { + try { + for await (const chunk of cp) { + if (streamClosed || currentRun !== runId) + return + controller?.enqueue(chunk) + } + if (currentRun === runId) + closeStream() + } + catch (error) { + if (currentRun === runId) + errorStream(error) + } + })() + + return cp + } + + cp = createChildProcess() + + const restart = async () => { + cp?.kill() + cp = createChildProcess() + } + const terminate = async () => { + cp?.kill() + cp = undefined + closeStream() + } + + const session: DevframeChildProcessTerminalSession = { + ...terminal, + status: 'running', + stream, + type: 'child-process', + executeOptions, + getChildProcess: () => cp?.process, + terminate, + restart, + } + this.register(session) + + return Promise.resolve(session) + } +} diff --git a/packages/hub/src/node/index.ts b/packages/hub/src/node/index.ts new file mode 100644 index 0000000..c5160de --- /dev/null +++ b/packages/hub/src/node/index.ts @@ -0,0 +1,8 @@ +export * from './context' +export * from './host-commands' +export * from './host-docks' +export * from './host-messages' +export * from './host-terminals' +export * from './mount-devframe' +export * from './rpc-builtins' +export * from './utils' diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts new file mode 100644 index 0000000..9b0333b --- /dev/null +++ b/packages/hub/src/node/mount-devframe.ts @@ -0,0 +1,52 @@ +import type { DevframeDefinition } from 'devframe/types' +import type { DevframeViewIframe } from '../types/docks' +import type { DevframeHubContext } from './context' +import { resolveBasePath } from 'devframe/node/hub-internals' +import { resolve } from 'pathe' + +export interface MountDevframeOptions { + /** + * Mount path override. Defaults to `d.basePath` or `/__${d.id}/`. + */ + base?: string + /** + * Overrides for the auto-synthesized iframe dock entry. Use this to + * customize the entry's `category`, override the icon, hide it via + * `when`, etc. Cannot change `id`, `type`, or `url` — those are + * derived from the devframe definition. + */ + dock?: Partial> +} + +/** + * Framework-neutral primitive — mounts a {@link DevframeDefinition} as a + * dock inside a hub-aware context: serves the devframe's SPA at the + * resolved base path, synthesizes an iframe dock entry from the + * definition's metadata, and runs the definition's `setup(ctx)`. + * + * Framework kits wrap this with their own plugin/middleware machinery — + * e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a + * Vite `Plugin` whose `devtools.setup` ultimately delegates here. + */ +export async function mountDevframe( + ctx: DevframeHubContext, + d: DevframeDefinition, + options: MountDevframeOptions = {}, +): Promise { + const base = options.base ?? resolveBasePath(d, 'hosted') + + if (d.cli?.distDir) { + ctx.views.hostStatic(base, resolve(d.cli.distDir)) + } + + ctx.docks.register({ + id: d.id, + title: d.name, + icon: d.icon ?? 'ph:plug-duotone', + ...options.dock, + type: 'iframe', + url: base, + } as DevframeViewIframe) + + await d.setup(ctx) +} diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts new file mode 100644 index 0000000..f250936 --- /dev/null +++ b/packages/hub/src/node/rpc-builtins.ts @@ -0,0 +1,30 @@ +import type { RpcFunctionDefinitionAny } from 'devframe/rpc' +import { defineHubRpcFunction } from '../define' + +/** + * `hub:commands:execute` — Invoke a registered server command by id. The + * arguments after `id` are forwarded to the command's `handler(...)`. + * Returns whatever the handler returns. + * + * Pairs with the `devframe:commands` shared state: clients read the list + * from the shared state and dispatch by id via this RPC. + */ +export const hubCommandsExecute = defineHubRpcFunction({ + name: 'hub:commands:execute', + type: 'action', + setup: context => ({ + async handler(id: string, ...args: any[]) { + return context.commands.execute(id, ...args) + }, + }), +}) + +/** + * Framework-neutral RPC declarations auto-registered by + * {@link createHubContext}. Provide additional RPCs by passing your own + * array via `CreateHubContextOptions.builtinRpcDeclarations`; the hub's + * list is prepended automatically. + */ +export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ + hubCommandsExecute, +] diff --git a/packages/hub/src/node/utils.ts b/packages/hub/src/node/utils.ts new file mode 100644 index 0000000..2c3d0b2 --- /dev/null +++ b/packages/hub/src/node/utils.ts @@ -0,0 +1,17 @@ +import type { ClientScriptEntry } from '../types/docks' +import { toDataURL } from 'mlly' + +/** + * Create a quick `ClientScriptEntry` from an inline function or + * stringified code. Useful for prototyping `action` / `renderer` + * dock entries without setting up a separate importable module. + * + * @experimental Prefer a proper importable module for production use. + */ +export function createSimpleClientScript(fn: string | ((ctx: any) => void)): ClientScriptEntry { + const code = `const fn = ${fn.toString()}; export default fn` + return { + importFrom: toDataURL(code), + importName: 'default', + } +} diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts new file mode 100644 index 0000000..699cf87 --- /dev/null +++ b/packages/hub/src/types/commands.ts @@ -0,0 +1,130 @@ +import type { EventEmitter } from 'devframe/types' + +export interface DevframeCommandKeybinding { + /** + * Keyboard shortcut string. + * Use "Mod" for platform-aware modifier (Cmd on macOS, Ctrl elsewhere). + * Examples: "Mod+K", "Mod+Shift+P", "Alt+N" + */ + key: string +} + +export interface DevframeCommandBase { + /** + * Unique namespaced ID, e.g. "vite:open-in-editor" + */ + id: string + title: string + description?: string + /** + * Iconify icon string, e.g. "ph:pencil-duotone" + */ + icon?: string + category?: string + /** + * Whether to show in command palette. Default: true + * + * - `true` — show the command and flatten its children into search results + * - `false` — hide the command entirely from the palette + * - `'without-children'` — show the command but don't flatten children into top-level search (children are still accessible via drill-down) + */ + showInPalette?: boolean | 'without-children' + /** + * Optional context expression for conditional visibility. + * When set, the command is only shown in the palette and only executable + * when the expression evaluates to true. + */ + when?: string + /** + * Default keyboard shortcut(s) for this command + */ + keybindings?: DevframeCommandKeybinding[] +} + +/** + * Server command input — what plugins pass to `ctx.commands.register()`. + */ +export interface DevframeServerCommandInput extends DevframeCommandBase { + /** + * Handler for this command. Optional if the command only serves as a group for children. + */ + handler?: (...args: any[]) => any | Promise + /** + * Static sub-commands. Two levels max (parent → children). + * Each child must have a globally unique `id`. + */ + children?: DevframeServerCommandInput[] +} + +/** + * Serializable server command entry — sent over RPC (no handler). + */ +export interface DevframeServerCommandEntry extends DevframeCommandBase { + source: 'server' + children?: DevframeServerCommandEntry[] +} + +/** + * Client command — registered in the webcomponent context. + */ +export interface DevframeClientCommand extends DevframeCommandBase { + source: 'client' + /** + * Action for this command. Optional if the command only serves as a group for children. + * Return sub-commands for dynamic nested palette menus (runtime submenus). + */ + action?: (...args: any[]) => void | DevframeClientCommand[] | Promise + /** + * Static sub-commands. Two levels max (parent → children). + */ + children?: DevframeClientCommand[] +} + +/** + * Union of command entries visible in the palette. + */ +export type DevframeCommandEntry = DevframeServerCommandEntry | DevframeClientCommand + +export interface DevframeCommandHandle { + readonly id: string + update: (patch: Partial>) => void + unregister: () => void +} + +export interface DevframeCommandsHostEvents { + 'command:registered': (command: DevframeServerCommandEntry) => void + 'command:unregistered': (id: string) => void +} + +export interface DevframeCommandsHost { + readonly commands: Map + readonly events: EventEmitter + + /** + * Register a command (with optional children). + */ + register: (command: DevframeServerCommandInput) => DevframeCommandHandle + + /** + * Unregister a command by ID (removes parent and all children). + */ + unregister: (id: string) => boolean + + /** + * Execute a command by ID. Searches top-level and children. + * Throws if not found or if command has no handler. + */ + execute: (id: string, ...args: any[]) => Promise + + /** + * Returns serializable list (no handlers), preserving tree structure. + */ + list: () => DevframeServerCommandEntry[] +} + +export interface DevframeCommandShortcutOverrides { + /** + * Command ID → keybinding overrides. Empty array = shortcut disabled. + */ + [commandId: string]: DevframeCommandKeybinding[] +} diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts new file mode 100644 index 0000000..9062ea1 --- /dev/null +++ b/packages/hub/src/types/docks.ts @@ -0,0 +1,176 @@ +import type { ConnectionMeta, EventEmitter } from 'devframe/types' +import type { JsonRenderer } from './json-render' + +export interface DevframeDocksHost { + readonly views: Map + readonly events: EventEmitter<{ + 'dock:entry:updated': (entry: DevframeDockUserEntry) => void + }> + + register: (entry: T, force?: boolean) => { + update: (patch: Partial) => void + } + update: (entry: DevframeDockUserEntry) => void + values: (options?: { includeBuiltin?: boolean }) => DevframeDockEntry[] +} + +// Known categories the hub orders by default. Kits may pass their own +// category ids; `(string & {})` keeps autocomplete on the known set while +// allowing arbitrary string values. +export type DevframeDockEntryCategory + = | 'app' + | 'framework' + | 'web' + | 'advanced' + | 'default' + | '~builtin' + | (string & {}) + +export type DevframeDockEntryIcon = string | { light: string, dark: string } + +export interface DevframeDockEntryBase { + id: string + title: string + icon: DevframeDockEntryIcon + /** + * The default order of the entry in the dock. + * The higher the number the earlier it appears. + * @default 0 + */ + defaultOrder?: number + /** + * The category of the entry + * @default 'default' + */ + category?: DevframeDockEntryCategory + /** + * Conditional visibility expression. + * When set, the dock entry is only visible when the expression evaluates to true. + * Uses the same syntax as command `when` clauses. + * + * Set to `'false'` to unconditionally hide the entry. + * + * @example 'clientType == embedded' + * @see {@link import('devframe/utils/when').evaluateWhen} + */ + when?: string + /** + * Badge text to display on the dock icon (e.g., unread count) + */ + badge?: string +} + +export interface ClientScriptEntry { + /** + * The filepath or module name to import from + */ + importFrom: string + /** + * The name to import the module as + * + * @default 'default' + */ + importName?: string +} + +export interface DevframeViewIframe extends DevframeDockEntryBase { + type: 'iframe' + url: string + /** + * The id of the iframe, if multiple tabs is assigned with the same id, the iframe will be shared. + * + * When not provided, it would be treated as a unique frame. + */ + frameId?: string + /** + * Optional client script to import into the iframe + */ + clientScript?: ClientScriptEntry + /** + * Enable remote-UI mode: the hub injects a connection descriptor + * (WS URL + pre-approved auth token) into the iframe URL so a hosted + * page can connect back via `connectRemoteDevframe()` from + * `@devframes/hub/client` — without needing to ship a dist with the + * plugin. + * + * Requires dev mode (no effect in build mode — no WS server exists). + * When enabled, the dock is automatically hidden in build mode unless + * the author provides an explicit `when` clause. + */ + remote?: boolean | RemoteDockOptions +} + +export interface RemoteDockOptions { + /** + * How to pass the connection descriptor to the hosted page. + * + * - `'fragment'` (default): appended as a URL fragment. + * Not sent in HTTP requests or Referer headers — safest for auth tokens. + * - `'query'`: appended as a URL query parameter. Use when your hosting + * platform rewrites fragments or your SPA router repurposes the fragment + * for navigation. The token will appear in server access logs and + * outbound Referer headers. + * + * @default 'fragment' + */ + transport?: 'fragment' | 'query' + /** + * Reject WS handshakes whose `Origin` header doesn't match the dock URL + * origin. Turn off when the same hosted app is served from multiple + * origins (e.g. preview deploys). + * + * @default true + */ + originLock?: boolean +} + +export interface RemoteConnectionInfo extends ConnectionMeta { + backend: 'websocket' + websocket: string + v: 1 + authToken: string + origin: string +} + +export type DevframeViewLauncherStatus = 'idle' | 'loading' | 'success' | 'error' + +export interface DevframeViewLauncher extends DevframeDockEntryBase { + type: 'launcher' + launcher: { + icon?: DevframeDockEntryIcon + title: string + status?: DevframeViewLauncherStatus + error?: string + description?: string + buttonStart?: string + buttonLoading?: string + onLaunch: () => Promise + } +} + +export interface DevframeViewAction extends DevframeDockEntryBase { + type: 'action' + action: ClientScriptEntry +} + +export interface DevframeViewCustomRender extends DevframeDockEntryBase { + type: 'custom-render' + renderer: ClientScriptEntry +} + +export interface DevframeViewBuiltin extends DevframeDockEntryBase { + type: '~builtin' + id: '~terminals' | '~messages' | '~client-auth-notice' | '~settings' | '~popup' +} + +export interface DevframeViewJsonRender extends DevframeDockEntryBase { + type: 'json-render' + /** JsonRenderer handle created by ctx.createJsonRenderer() */ + ui: JsonRenderer +} + +export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender + +export type DevframeDockEntry = DevframeDockUserEntry | DevframeViewBuiltin + +export type DevframeDockEntriesGrouped = [category: string, entries: DevframeDockEntry[]][] diff --git a/packages/hub/src/types/index.ts b/packages/hub/src/types/index.ts new file mode 100644 index 0000000..e97130e --- /dev/null +++ b/packages/hub/src/types/index.ts @@ -0,0 +1,44 @@ +// Re-export the hub-augmented context type so consumers can import it +// from the hub's main `types` barrel. +export type { CreateHubContextOptions, DevframeHubContext } from '../node/context' + +export * from './commands' +export * from './docks' +export * from './json-render' +export * from './messages' +export * from './settings' +export * from './terminals' + +export type { RpcDefinitionsFilter, RpcDefinitionsToFunctions } from 'devframe/rpc' + +// NOTE: we re-export devframe's types individually rather than using +// `export * from 'devframe/types'` because the rolldown-plugin-dts step +// fails with a `MemberExpression` AST error on namespace re-exports +// from external packages (tsdown 0.21 / rolldown-plugin-dts 0.23). +// Revisit once upstream supports it. +export type { + ConnectionMeta, + DevframeCapabilities, + DevframeDiagnosticsDefinition, + DevframeDiagnosticsHost, + DevframeDiagnosticsLogger, + DevframeHost, + DevframeNodeRpcSession, + DevframeRpcClientFunctions, + DevframeRpcServerFunctions, + DevframeRpcSharedStates, + DevframeViewHost, + EntriesToObject, + EventEmitter, + EventsMap, + EventUnsubscribe, + PartialWithoutId, + RpcBroadcastOptions, + RpcFunctionsHost, + RpcSharedStateGetOptions, + RpcSharedStateHost, + RpcStreamingChannel, + RpcStreamingChannelOptions, + RpcStreamingHost, + Thenable, +} from 'devframe/types' diff --git a/packages/hub/src/types/json-render.ts b/packages/hub/src/types/json-render.ts new file mode 100644 index 0000000..336af7c --- /dev/null +++ b/packages/hub/src/types/json-render.ts @@ -0,0 +1,29 @@ +export interface JsonRenderElement { + type: string + props?: Record + children?: string[] + /** json-render event bindings (e.g. `{ press: { action: "my:action" } }`) */ + on?: Record + /** json-render visibility condition */ + visible?: unknown + /** json-render repeat binding */ + repeat?: unknown + /** Allow additional json-render element fields */ + [key: string]: unknown +} + +export interface JsonRenderSpec { + root: string + elements: Record + /** Initial client-side state model for $state/$bindState expressions */ + state?: Record +} + +export interface JsonRenderer { + /** Replace the entire spec */ + updateSpec: (spec: JsonRenderSpec) => void | Promise + /** Update json-render state values (shallow merge into spec.state) */ + updateState: (state: Record) => void | Promise + /** Internal: shared state key used by the client to subscribe */ + readonly _stateKey: string +} diff --git a/packages/hub/src/types/messages.ts b/packages/hub/src/types/messages.ts new file mode 100644 index 0000000..43ba3c9 --- /dev/null +++ b/packages/hub/src/types/messages.ts @@ -0,0 +1,146 @@ +import type { EventEmitter } from 'devframe/types' + +export type DevframeMessageLevel = 'info' | 'warn' | 'error' | 'success' | 'debug' +export type DevframeMessageEntryFrom = 'server' | 'browser' + +export interface DevframeMessageElementPosition { + /** CSS selector for the element */ + selector?: string + /** Bounding box of the element */ + boundingBox?: { x: number, y: number, width: number, height: number } + /** Human-readable description of the element */ + description?: string +} + +export interface DevframeMessageFilePosition { + /** Absolute or relative file path */ + file: string + /** Line number (1-based) */ + line?: number + /** Column number (1-based) */ + column?: number +} + +export interface DevframeMessageEntry { + /** + * Unique identifier for this message entry (auto-generated if not provided) + */ + id: string + /** + * Short title or summary of the message + */ + message: string + /** + * Optional detailed description or explanation + */ + description?: string + /** + * Severity level, determines color and icon + */ + level: DevframeMessageLevel + /** + * Optional stack trace string + */ + stacktrace?: string + /** + * Optional DOM element position info (e.g., for a11y issues) + */ + elementPosition?: DevframeMessageElementPosition + /** + * Optional source file position info (e.g., for lint errors) + */ + filePosition?: DevframeMessageFilePosition + /** + * Whether this message should also appear as a toast notification + */ + notify?: boolean + /** + * Origin of the message entry, automatically set by the context + */ + from: DevframeMessageEntryFrom + /** + * Grouping category (e.g., 'a11y', 'lint', 'runtime', 'test') + */ + category?: string + /** + * Optional tags/labels for filtering + */ + labels?: string[] + /** + * Time in ms to auto-dismiss the toast notification (client-side) + */ + autoDismiss?: number + /** + * Time in ms to auto-delete this message entry (server-side) + */ + autoDelete?: number + /** + * Timestamp when the message was created (auto-generated if not provided) + */ + timestamp: number + /** + * Status of the message entry (e.g., 'loading' while an operation is in progress). + * Defaults to 'idle' when not specified. + */ + status?: 'loading' | 'idle' +} + +/** + * Input type for creating a message entry. + * `id`, `timestamp`, and `from` are auto-filled by the host. + */ +export type DevframeMessageEntryInput = Omit & { + id?: string + timestamp?: number +} + +export interface DevframeMessageHandle { + /** The underlying message entry data */ + readonly entry: DevframeMessageEntry + /** Shortcut to entry.id */ + readonly id: string + /** Partial update of this message entry */ + update: (patch: Partial) => Promise + /** Remove this message entry */ + dismiss: () => Promise +} + +export interface DevframeMessagesClient { + /** + * Add a message entry. Returns a Promise resolving to a handle for subsequent updates/dismissal. + * Can be used without `await` for fire-and-forget usage. + */ + add: (input: DevframeMessageEntryInput) => Promise + /** Remove a message entry by id */ + remove: (id: string) => Promise + /** Clear all message entries */ + clear: () => Promise +} + +export interface DevframeMessagesHost { + readonly entries: Map + readonly events: EventEmitter<{ + 'message:added': (entry: DevframeMessageEntry) => void + 'message:updated': (entry: DevframeMessageEntry) => void + 'message:removed': (id: string) => void + 'message:cleared': () => void + }> + + /** + * Add a new message entry. If an entry with the same `id` already exists, it will be updated instead. + * Returns a handle for subsequent updates/dismissal. Can be used without `await` for fire-and-forget. + */ + add: (entry: DevframeMessageEntryInput) => Promise + /** + * Update an existing message entry by id (partial update) + */ + update: (id: string, patch: Partial) => Promise + /** + * Remove a message entry by id + */ + remove: (id: string) => Promise + /** + * Clear all message entries + */ + clear: () => Promise +} diff --git a/packages/hub/src/types/settings.ts b/packages/hub/src/types/settings.ts new file mode 100644 index 0000000..02eb633 --- /dev/null +++ b/packages/hub/src/types/settings.ts @@ -0,0 +1,11 @@ +import type { DevframeCommandShortcutOverrides } from './commands' + +export interface DevframeDocksUserSettings { + docksHidden: string[] + docksCategoriesHidden: string[] + docksPinned: string[] + docksCustomOrder: Record + showIframeAddressBar: boolean + closeOnOutsideClick: boolean + commandShortcuts: DevframeCommandShortcutOverrides +} diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts new file mode 100644 index 0000000..c34251e --- /dev/null +++ b/packages/hub/src/types/terminals.ts @@ -0,0 +1,48 @@ +import type { EventEmitter } from 'devframe/types' +import type { ChildProcess } from 'node:child_process' +import type { DevframeDockEntryIcon } from './docks' + +export interface DevframeTerminalsHost { + readonly sessions: Map + readonly events: EventEmitter<{ + 'terminal:session:updated': (session: DevframeTerminalSession) => void + }> + + register: (session: DevframeTerminalSession) => DevframeTerminalSession + update: (session: DevframeTerminalSession) => void + + startChildProcess: ( + executeOptions: DevframeChildProcessExecuteOptions, + terminal: Omit, + ) => Promise +} + +export type DevframeTerminalStatus = 'running' | 'stopped' | 'error' + +export interface DevframeTerminalSessionBase { + id: string + title: string + description?: string + status: DevframeTerminalStatus + icon?: DevframeDockEntryIcon +} + +export interface DevframeTerminalSession extends DevframeTerminalSessionBase { + buffer?: string[] + stream?: ReadableStream +} + +export interface DevframeChildProcessExecuteOptions { + command: string + args: string[] + cwd?: string + env?: Record +} + +export interface DevframeChildProcessTerminalSession extends DevframeTerminalSession { + type: 'child-process' + executeOptions: DevframeChildProcessExecuteOptions + getChildProcess: () => ChildProcess | undefined + terminate: () => Promise + restart: () => Promise +} diff --git a/packages/hub/src/utils/diagnostics-reporter.ts b/packages/hub/src/utils/diagnostics-reporter.ts new file mode 100644 index 0000000..335b773 --- /dev/null +++ b/packages/hub/src/utils/diagnostics-reporter.ts @@ -0,0 +1,12 @@ +import type { Diagnostic } from 'nostics' +import { colors as c } from 'devframe/utils/colors' +import { ansiFormatter } from 'nostics/formatters/ansi' + +const formatAnsi = ansiFormatter(c) + +export interface HubReporterOptions { method?: 'log' | 'warn' | 'error' } + +export function hubReporter(d: Diagnostic, { method = 'warn' }: HubReporterOptions = {}): void { + // eslint-disable-next-line no-console + console[method](formatAnsi(d)) +} diff --git a/packages/hub/tsconfig.json b/packages/hub/tsconfig.json new file mode 100644 index 0000000..8a6d5a7 --- /dev/null +++ b/packages/hub/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["esnext", "dom"] + } +} diff --git a/packages/hub/tsdown.config.ts b/packages/hub/tsdown.config.ts new file mode 100644 index 0000000..d8c17bb --- /dev/null +++ b/packages/hub/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'constants': 'src/constants.ts', + 'client/index': 'src/client/index.ts', + 'node/index': 'src/node/index.ts', + 'types/index': 'src/types/index.ts', + }, + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + clean: true, + tsconfig: '../../tsconfig.base.json', + dts: true, + platform: 'neutral', + deps: { + neverBundle: [ + 'vite', + 'esbuild', + 'postcss', + 'rolldown', + ], + onlyBundle: [ + 'acorn', + 'mlly', + ], + }, +}) diff --git a/packages/nuxt/src/runtime/plugin.client.ts b/packages/nuxt/src/runtime/plugin.client.ts index 43061f1..af2bbfc 100644 --- a/packages/nuxt/src/runtime/plugin.client.ts +++ b/packages/nuxt/src/runtime/plugin.client.ts @@ -11,7 +11,7 @@ export default defineNuxtPlugin({ const rpc = await connectDevframe({ baseURL }) return { provide: { - rpc: rpc as import('devframe/client').DevToolsRpcClient, + rpc: rpc as import('devframe/client').DevframeRpcClient, }, } }, diff --git a/packages/nuxt/src/runtime/types.d.ts b/packages/nuxt/src/runtime/types.d.ts index 40a5898..c3f3d5a 100644 --- a/packages/nuxt/src/runtime/types.d.ts +++ b/packages/nuxt/src/runtime/types.d.ts @@ -1,4 +1,4 @@ -import type { DevToolsRpcClient } from 'devframe/client' +import type { DevframeRpcClient } from 'devframe/client' declare module '#app' { interface NuxtApp { @@ -6,14 +6,14 @@ declare module '#app' { * Devframe RPC client, provided by the `@devframes/nuxt` module's * client plugin. */ - $rpc: DevToolsRpcClient + $rpc: DevframeRpcClient } } declare module 'vue' { interface ComponentCustomProperties { /** Devframe RPC client (see `NuxtApp['$rpc']`). */ - $rpc: DevToolsRpcClient + $rpc: DevframeRpcClient } } diff --git a/packages/nuxt/tsdown.config.ts b/packages/nuxt/tsdown.config.ts index 9dd368b..d8ac6b8 100644 --- a/packages/nuxt/tsdown.config.ts +++ b/packages/nuxt/tsdown.config.ts @@ -47,9 +47,9 @@ export default defineConfig([{ await Promise.all([ fs.cp('src/runtime/types.d.ts', 'dist/runtime/types.d.ts'), fs.writeFile('dist/runtime/plugin.client.d.ts', `import type { Plugin } from '#app'; -import type { DevToolsRpcClient } from 'devframe/client'; +import type { DevframeRpcClient } from 'devframe/client'; declare const plugin: Plugin<{ - rpc: DevToolsRpcClient; + rpc: DevframeRpcClient; }>; export default plugin; `, 'utf-8'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51cbb7..0070e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ catalogs: structured-clone-es: specifier: ^2.0.0 version: 2.0.0 + tinyexec: + specifier: ^1.1.2 + version: 1.1.2 tinyglobby: specifier: ^0.2.16 version: 0.2.16 @@ -97,28 +100,6 @@ catalogs: ws: specifier: ^8.20.0 version: 8.20.0 - devtools: - '@antfu/eslint-config': - specifier: ^9.0.0 - version: 9.0.0 - bumpp: - specifier: ^11.1.0 - version: 11.1.0 - eslint: - specifier: ^10.3.0 - version: 10.3.0 - nano-staged: - specifier: ^1.0.2 - version: 1.0.2 - simple-git-hooks: - specifier: ^2.13.1 - version: 2.13.1 - skills-npm: - specifier: ^1.1.1 - version: 1.1.1 - typescript: - specifier: ^6.0.3 - version: 6.0.3 docs: mermaid: specifier: ^11.15.0 @@ -159,6 +140,28 @@ catalogs: vitest: specifier: ^4.1.6 version: 4.1.6 + tooling: + '@antfu/eslint-config': + specifier: ^9.0.0 + version: 9.0.0 + bumpp: + specifier: ^11.1.0 + version: 11.1.0 + eslint: + specifier: ^10.3.0 + version: 10.3.0 + nano-staged: + specifier: ^1.0.2 + version: 1.0.2 + simple-git-hooks: + specifier: ^2.13.1 + version: 2.13.1 + skills-npm: + specifier: ^1.1.1 + version: 1.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 types: '@types/node': specifier: ^25.7.0 @@ -182,7 +185,7 @@ importers: .: devDependencies: '@antfu/eslint-config': - specifier: catalog:devtools + specifier: catalog:tooling version: 9.0.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.7.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))) '@antfu/ni': specifier: catalog:build @@ -200,19 +203,19 @@ importers: specifier: catalog:types version: 8.18.1 bumpp: - specifier: catalog:devtools + specifier: catalog:tooling version: 11.1.0 eslint: - specifier: catalog:devtools + specifier: catalog:tooling version: 10.3.0(jiti@2.7.0) nano-staged: - specifier: catalog:devtools + specifier: catalog:tooling version: 1.0.2 simple-git-hooks: - specifier: catalog:devtools + specifier: catalog:tooling version: 2.13.1 skills-npm: - specifier: catalog:devtools + specifier: catalog:tooling version: 1.1.1 tsdown: specifier: catalog:build @@ -227,7 +230,7 @@ importers: specifier: catalog:build version: 2.9.12 typescript: - specifier: catalog:devtools + specifier: catalog:tooling version: 6.0.3 vite: specifier: catalog:build @@ -285,6 +288,62 @@ importers: specifier: catalog:deps version: 8.20.0 + examples/minimal-next-devframe-hub: + dependencies: + '@devframes/hub': + specifier: workspace:* + version: link:../../packages/hub + devframe: + specifier: workspace:* + version: link:../../packages/devframe + next: + specifier: catalog:frontend + version: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: + specifier: catalog:frontend + version: 19.2.6 + react-dom: + specifier: catalog:frontend + version: 19.2.6(react@19.2.6) + devDependencies: + '@types/react': + specifier: catalog:types + version: 19.2.15 + '@types/react-dom': + specifier: catalog:types + version: 19.2.3(@types/react@19.2.15) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 + vitest: + specifier: catalog:testing + version: 4.1.6(@types/node@25.7.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)) + ws: + specifier: catalog:deps + version: 8.20.0 + + examples/minimal-vite-devframe-hub: + dependencies: + '@devframes/hub': + specifier: workspace:* + version: link:../../packages/hub + devframe: + specifier: workspace:* + version: link:../../packages/devframe + devDependencies: + get-port-please: + specifier: catalog:deps + version: 3.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 + vite: + specifier: catalog:build + version: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4) + examples/next-runtime-snapshot: dependencies: devframe: @@ -423,6 +482,34 @@ importers: specifier: catalog:deps version: 0.1.2 + packages/hub: + dependencies: + birpc: + specifier: catalog:deps + version: 4.0.0 + nostics: + specifier: catalog:deps + version: 0.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 + perfect-debounce: + specifier: catalog:deps + version: 2.1.0 + tinyexec: + specifier: catalog:deps + version: 1.1.2 + devDependencies: + devframe: + specifier: workspace:* + version: link:../devframe + mlly: + specifier: catalog:build + version: 1.8.2 + tsdown: + specifier: catalog:build + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3) + packages/nuxt: devDependencies: '@nuxt/kit': @@ -6962,7 +7049,7 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.7.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))) - ansis: 4.2.0 + ansis: 4.3.0 cac: 7.0.0 eslint: 10.3.0(jiti@2.7.0) eslint-config-flat-gitignore: 2.3.0(eslint@10.3.0(jiti@2.7.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0f179c..4b54674 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,18 +50,11 @@ catalogs: pathe: ^2.0.3 perfect-debounce: ^2.1.0 structured-clone-es: ^2.0.0 + tinyexec: ^1.1.2 tinyglobby: ^0.2.16 valibot: ^1.4.0 whenexpr: ^0.1.2 ws: ^8.20.0 - devtools: - '@antfu/eslint-config': ^9.0.0 - bumpp: ^11.1.0 - eslint: ^10.3.0 - nano-staged: ^1.0.2 - simple-git-hooks: ^2.13.1 - skills-npm: ^1.1.1 - typescript: ^6.0.3 docs: mermaid: ^11.15.0 vitepress: ^2.0.0-alpha.17 @@ -78,6 +71,14 @@ catalogs: '@playwright/test': ^1.50.0 tsnapi: ^0.3.3 vitest: ^4.1.6 + tooling: + '@antfu/eslint-config': ^9.0.0 + bumpp: ^11.1.0 + eslint: ^10.3.0 + nano-staged: ^1.0.2 + simple-git-hooks: ^2.13.1 + skills-npm: ^1.1.1 + typescript: ^6.0.3 types: '@types/node': ^25.7.0 '@types/react': ^19.2.15 diff --git a/scripts/ecosystem-ci.ts b/scripts/ecosystem-ci.ts index 6858650..02b3f0c 100644 --- a/scripts/ecosystem-ci.ts +++ b/scripts/ecosystem-ci.ts @@ -40,7 +40,7 @@ async function main(): Promise { } async function resolveRef(): Promise { - const override = process.env.ECOSYSTEM_DEVTOOLS_REF + const override = process.env.ECOSYSTEM_DEVFRAME_REF if (override) return override diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 5c39507..07b5c6b 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -94,7 +94,7 @@ import { listFiles } from './functions/list-files' export const serverFunctions = [getCwd, listFiles] as const declare module 'devframe' { - interface DevToolsRpcServerFunctions + interface DevframeRpcServerFunctions extends import('devframe/rpc').RpcDefinitionsToFunctions {} } ``` @@ -116,24 +116,24 @@ export default defineDevframe({ ### Sharing setup-time state via `src/context.ts` -When per-file RPCs need access to runtime values that `setup(ctx)` constructs once — streaming channels, shared state handles, watchers, loaders, caches — expose them through a `WeakMap` in a sibling `src/context.ts`. This mirrors the framework's own `internalContextMap` in `packages/devframe/src/node/internal/context.ts`. The WeakMap keys off the existing `DevToolsNodeContext` so contexts are garbage-collected automatically when the host tears down. +When per-file RPCs need access to runtime values that `setup(ctx)` constructs once — streaming channels, shared state handles, watchers, loaders, caches — expose them through a `WeakMap` in a sibling `src/context.ts`. This mirrors the framework's own `internalContextMap` in `packages/devframe/src/node/hub-internals/context.ts`. The WeakMap keys off the existing `DevframeNodeContext` so contexts are garbage-collected automatically when the host tears down. ```ts // src/context.ts -import type { DevToolsNodeContext } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' export interface MyToolContext { loaders: { list: () => Promise } // …channels, shared state handles, watchers, etc. } -const map = new WeakMap() +const map = new WeakMap() -export function setMyToolContext(ctx: DevToolsNodeContext, value: MyToolContext): void { +export function setMyToolContext(ctx: DevframeNodeContext, value: MyToolContext): void { map.set(ctx, value) } -export function getMyToolContext(ctx: DevToolsNodeContext): MyToolContext { +export function getMyToolContext(ctx: DevframeNodeContext): MyToolContext { const value = map.get(ctx) if (!value) throw new Error('my-tool context not initialised — call setMyToolContext in devframe.setup') @@ -153,7 +153,7 @@ Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)` 'get-modules' // ✗ — may collide with other devframes sharing the host ``` -## DevToolsNodeContext at a glance +## DevframeNodeContext at a glance `setup(ctx)` receives the framework-neutral server-side surface. Each host corresponds to a [docs](https://devfra.me/) page: @@ -417,9 +417,9 @@ const rpc = await connectDevframe() const data = await rpc.call('my-inspector:get-stats', { limit: 10 }) ``` -`connectDevframe` auto-detects the backend via `/.devtools/.connection.json`: +`connectDevframe` auto-detects the backend via `/.devframe/.connection.json`: -- **websocket** (dev mode) — full read/write, requires auth handshake. Listen for token updates on the `vite-devtools-auth` BroadcastChannel. +- **websocket** (dev mode) — full read/write, requires auth handshake. Listen for token updates on the `devframe-auth` BroadcastChannel. - **static** (build / spa output) — read-only, resolves calls from the baked RPC dump. Use `rpc.sharedState.get(key)` for observable state, `rpc.client.register(defineRpcFunction(...))` to receive server broadcasts, and `rpc.callOptional(...)` when a missing handler should resolve to `undefined` instead of throwing. @@ -450,7 +450,7 @@ At runtime, static clients look up the argument hash in the dump; misses resolve | Subcommand | Action | |------------|--------| -| *(default)* | Dev server on port 9999 (or `--port`) — WebSocket RPC, `cli.distDir` served at `/.devtools/` | +| *(default)* | Dev server on port 9999 (or `--port`) — WebSocket RPC, `cli.distDir` served at `/.devframe/` | | `build` | Static snapshot → `./dist-static/` (configurable via `--out-dir`) | | `spa` | Deployable SPA → `./dist-spa/` | | `mcp` | stdio MCP server (experimental) | @@ -482,7 +482,7 @@ For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers - Unit-test host classes with fake contexts. - Run `templates/counter-devframe.ts` under each adapter for integration coverage. -- Snapshot the build-static RPC dump (`/.devtools/.rpc-dump/index.json`) to catch accidental drift in `static` function outputs. +- Snapshot the build-static RPC dump (`/.devframe/.rpc-dump/index.json`) to catch accidental drift in `static` function outputs. ## Further reading diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts new file mode 100644 index 0000000..7f61b9f --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -0,0 +1,96 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/client` + */ +// #region Interfaces +export interface CommandsContext { + readonly commands: DevframeCommandEntry[]; + readonly paletteCommands: DevframeCommandEntry[]; + register: (_: DevframeClientCommand | DevframeClientCommand[]) => () => void; + execute: (_: string, ..._: any[]) => Promise; + getKeybindings: (_: string) => DevframeCommandKeybinding[]; + settings: SharedState; + paletteOpen: boolean; +} +export interface DockClientScriptContext extends DocksContext { + current: DockEntryState; + messages: DevframeMessagesClient; +} +export interface DockEntryState { + entryMeta: DevframeDockEntry; + readonly isActive: boolean; + domElements: { + iframe?: HTMLIFrameElement | null; + panel?: HTMLDivElement | null; + }; + events: EventEmitter; +} +export interface DockEntryStateEvents { + 'entry:activated': () => void; + 'entry:deactivated': () => void; + 'entry:updated': (_: DevframeDockUserEntry) => void; + 'dom:panel:mounted': (_: HTMLDivElement) => void; + 'dom:iframe:mounted': (_: HTMLIFrameElement) => void; +} +export interface DockPanelStorage { + mode: 'float' | 'edge'; + width: number; + height: number; + top: number; + left: number; + position: 'left' | 'right' | 'bottom' | 'top'; + open: boolean; + inactiveTimeout: number; +} +export interface DocksContext extends DevframeRpcContext { + readonly clientType: 'embedded' | 'standalone'; + readonly panel: DocksPanelContext; + readonly docks: DocksEntriesContext; + readonly commands: CommandsContext; + readonly when: WhenClauseContext; +} +export interface DocksEntriesContext { + selectedId: string | null; + readonly selected: DevframeDockEntry | null; + entries: DevframeDockEntry[]; + entryToStateMap: Map; + groupedEntries: DevframeDockEntriesGrouped; + settings: SharedState; + getStateById: (_: string) => DockEntryState | undefined; + switchEntry: (_?: string | null) => Promise; + toggleEntry: (_: string) => Promise; +} +export interface DocksPanelContext { + store: DockPanelStorage; + isDragging: boolean; + isResizing: boolean; + readonly isVertical: boolean; +} +export interface WhenClauseContext { + readonly context: WhenContext; +} +// #endregion + +// #region Types +export type ConnectRemoteDevframeOptions = Omit; +export type DevframeClientContext = DocksContext; +export type DockClientType = 'embedded' | 'standalone'; +// #endregion + +// #region Functions +export declare function connectRemoteDevframe(_?: ConnectRemoteDevframeOptions): Promise; +export declare function getDevframeClientContext(): DevframeClientContext | undefined; +export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; +// #endregion + +// #region Variables +export declare const CLIENT_CONTEXT_KEY: string; +// #endregion + +// #region Re-exports +export * from "devframe/client"; +// #endregion + +// #region Other +export { DevframeClientRpcHost } +export { RpcClientEvents } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js new file mode 100644 index 0000000..4efacf2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/client` + */ +// #region Functions +export async function connectRemoteDevframe(_) {} +export function getDevframeClientContext() {} +export function parseRemoteConnection(_) {} +// #endregion + +// #region Variables +export var CLIENT_CONTEXT_KEY /* const */ +// #endregion + +// #region Re-exports +export * from "devframe/client"; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts new file mode 100644 index 0000000..cb54f03 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -0,0 +1,11 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/constants` + */ +// #region Variables +export declare const DEFAULT_CATEGORIES_ORDER: Record; +export declare const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings; +// #endregion + +// #region Re-exports +export * from "devframe/constants"; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js new file mode 100644 index 0000000..6189f36 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js @@ -0,0 +1,11 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/constants` + */ +// #region Variables +export var DEFAULT_CATEGORIES_ORDER /* const */ +export var DEFAULT_STATE_USER_SETTINGS /* const */ +// #endregion + +// #region Re-exports +export * from "devframe/constants"; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts new file mode 100644 index 0000000..5805bf1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -0,0 +1,93 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub` + */ +// #region Functions +export declare function defineCommand(_: Omit & { + when?: WhenExpression; +}): DevframeServerCommandInput; +export declare function defineDockEntry(_: Omit & { + when?: WhenExpression; +}): T; +export declare function defineJsonRenderSpec(_: JsonRenderSpec): JsonRenderSpec; +// #endregion + +// #region Variables +export declare const defineHubRpcFunction: (definition: _$devframe_rpc0.RpcFunctionDefinition) => _$devframe_rpc0.RpcFunctionDefinition; +// #endregion + +// #region Other +export { ClientScriptEntry } +export { ConnectionMeta } +export { CreateHubContextOptions } +export { DevframeCapabilities } +export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessTerminalSession } +export { DevframeClientCommand } +export { DevframeCommandBase } +export { DevframeCommandEntry } +export { DevframeCommandHandle } +export { DevframeCommandKeybinding } +export { DevframeCommandShortcutOverrides } +export { DevframeCommandsHost } +export { DevframeCommandsHostEvents } +export { DevframeDiagnosticsDefinition } +export { DevframeDiagnosticsHost } +export { DevframeDiagnosticsLogger } +export { DevframeDockEntriesGrouped } +export { DevframeDockEntry } +export { DevframeDockEntryBase } +export { DevframeDockEntryCategory } +export { DevframeDockEntryIcon } +export { DevframeDocksHost } +export { DevframeDocksUserSettings } +export { DevframeDockUserEntry } +export { DevframeHost } +export { DevframeHubContext } +export { DevframeMessageElementPosition } +export { DevframeMessageEntry } +export { DevframeMessageEntryFrom } +export { DevframeMessageEntryInput } +export { DevframeMessageFilePosition } +export { DevframeMessageHandle } +export { DevframeMessageLevel } +export { DevframeMessagesClient } +export { DevframeMessagesHost } +export { DevframeNodeRpcSession } +export { DevframeRpcClientFunctions } +export { DevframeRpcServerFunctions } +export { DevframeRpcSharedStates } +export { DevframeServerCommandEntry } +export { DevframeServerCommandInput } +export { DevframeTerminalSession } +export { DevframeTerminalSessionBase } +export { DevframeTerminalsHost } +export { DevframeTerminalStatus } +export { DevframeViewAction } +export { DevframeViewBuiltin } +export { DevframeViewCustomRender } +export { DevframeViewHost } +export { DevframeViewIframe } +export { DevframeViewJsonRender } +export { DevframeViewLauncher } +export { DevframeViewLauncherStatus } +export { EntriesToObject } +export { EventEmitter } +export { EventsMap } +export { EventUnsubscribe } +export { JsonRenderElement } +export { JsonRenderer } +export { JsonRenderSpec } +export { PartialWithoutId } +export { RemoteConnectionInfo } +export { RemoteDockOptions } +export { RpcBroadcastOptions } +export { RpcDefinitionsFilter } +export { RpcDefinitionsToFunctions } +export { RpcFunctionsHost } +export { RpcSharedStateGetOptions } +export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } +export { Thenable } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js new file mode 100644 index 0000000..b50f2b5 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js @@ -0,0 +1,9 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub` + */ +// #region Other +export { defineCommand } +export { defineDockEntry } +export { defineHubRpcFunction } +export { defineJsonRenderSpec } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts new file mode 100644 index 0000000..63820c5 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -0,0 +1,108 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/node` + */ +// #region Interfaces +export interface MountDevframeOptions { + base?: string; + dock?: Partial>; +} +// #endregion + +// #region Classes +export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { + readonly context: DevframeHubContext; + readonly commands: DevframeCommandsHost$1['commands']; + readonly events: DevframeCommandsHost$1['events']; + constructor(_: DevframeHubContext); + register(_: DevframeServerCommandInput): DevframeCommandHandle; + unregister(_: string): boolean; + execute(_: string, ..._: any[]): Promise; + list(): DevframeServerCommandEntry[]; + private findCommand; + private toSerializable; +} +export declare class DevframeDocksHost implements DevframeDocksHost$1 { + readonly context: DevframeHubContext; + readonly views: DevframeDocksHost$1['views']; + readonly events: DevframeDocksHost$1['events']; + userSettings: SharedState; + private readonly remoteDocks; + constructor(_: DevframeHubContext); + init(): Promise; + values({ + includeBuiltin + }?: { + includeBuiltin?: boolean; + }): DevframeDockEntry[]; + private projectView; + private resolveDevServerOrigin; + register(_: T, _?: boolean): { + update: (_: Partial) => void; + }; + update(_: DevframeDockUserEntry): void; + private prepareRemoteRegistration; +} +export declare class DevframeMessagesHost implements DevframeMessagesHost$1 { + readonly context: DevframeHubContext; + readonly entries: DevframeMessagesHost$1['entries']; + readonly events: DevframeMessagesHost$1['events']; + readonly lastModified: Map; + readonly removals: Array<{ + id: string; + time: number; + }>; + private _autoDeleteTimers; + private _clock; + private _tick; + constructor(_: DevframeHubContext); + add(_: DevframeMessageEntryInput): Promise; + update(_: string, _: Partial): Promise; + remove(_: string): Promise; + clear(): Promise; + private _createHandle; +} +export declare class DevframeTerminalsHost implements DevframeTerminalsHost$1 { + readonly context: DevframeHubContext; + readonly sessions: DevframeTerminalsHost$1['sessions']; + readonly events: DevframeTerminalsHost$1['events']; + private _boundStreams; + private _channel?; + constructor(_: DevframeHubContext); + private getStreamingChannel; + register(_: DevframeTerminalSession): DevframeTerminalSession; + update(_: PartialWithoutId): void; + remove(_: DevframeTerminalSession): void; + private bindStream; + startChildProcess(_: DevframeChildProcessExecuteOptions, _: Omit): Promise; +} +// #endregion + +// #region Functions +export declare function createSimpleClientScript(_: string | ((_: any) => void)): ClientScriptEntry; +export declare function mountDevframe(_: DevframeHubContext, _: DevframeDefinition, _?: MountDevframeOptions): Promise; +// #endregion + +// #region Variables +export declare const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[]; +export declare const hubCommandsExecute: { + name: "hub:commands:execute"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[id: string, ...args: any[]], Promise>>) | undefined; + handler?: ((id: string, ...args: any[]) => Promise) | undefined; + dump?: _$devframe_rpc0.RpcDump<[id: string, ...args: any[]], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[id: string, ...args: any[]], Promise>> | undefined; +}; +// #endregion + +// #region Other +export { createHubContext } +export { CreateHubContextOptions } +export { DevframeHubContext } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js new file mode 100644 index 0000000..576a32c --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -0,0 +1,73 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/node` + */ +// #region Classes +export class DevframeCommandsHost { + context + commands + events + constructor(_) {} + register(_) {} + unregister(_) {} + async execute(_, ..._) {} + list() {} + findCommand(_) {} + toSerializable(_) {} +} +export class DevframeDocksHost { + context + views + events + userSettings + remoteDocks + constructor(_) {} + async init() {} + values(_) {} + projectView(_) {} + resolveDevServerOrigin() {} + register(_, _) {} + update(_) {} + prepareRemoteRegistration(_) {} +} +export class DevframeMessagesHost { + context + entries + events + lastModified + removals + _autoDeleteTimers + _clock + _tick() {} + constructor(_) {} + async add(_) {} + async update(_, _) {} + async remove(_) {} + async clear() {} + _createHandle(_) {} +} +export class DevframeTerminalsHost { + context + sessions + events + _boundStreams + _channel + constructor(_) {} + getStreamingChannel() {} + register(_) {} + update(_) {} + remove(_) {} + bindStream(_) {} + async startChildProcess(_, _) {} +} +// #endregion + +// #region Functions +export async function createHubContext(_) {} +export function createSimpleClientScript(_) {} +export async function mountDevframe(_, _, _) {} +// #endregion + +// #region Variables +export var builtinHubRpcDeclarations /* const */ +export var hubCommandsExecute /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts new file mode 100644 index 0000000..b3b6446 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -0,0 +1,79 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/types` + */ +// #region Other +export { ClientScriptEntry } +export { ConnectionMeta } +export { CreateHubContextOptions } +export { DevframeCapabilities } +export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessTerminalSession } +export { DevframeClientCommand } +export { DevframeCommandBase } +export { DevframeCommandEntry } +export { DevframeCommandHandle } +export { DevframeCommandKeybinding } +export { DevframeCommandShortcutOverrides } +export { DevframeCommandsHost } +export { DevframeCommandsHostEvents } +export { DevframeDiagnosticsDefinition } +export { DevframeDiagnosticsHost } +export { DevframeDiagnosticsLogger } +export { DevframeDockEntriesGrouped } +export { DevframeDockEntry } +export { DevframeDockEntryBase } +export { DevframeDockEntryCategory } +export { DevframeDockEntryIcon } +export { DevframeDocksHost } +export { DevframeDocksUserSettings } +export { DevframeDockUserEntry } +export { DevframeHost } +export { DevframeHubContext } +export { DevframeMessageElementPosition } +export { DevframeMessageEntry } +export { DevframeMessageEntryFrom } +export { DevframeMessageEntryInput } +export { DevframeMessageFilePosition } +export { DevframeMessageHandle } +export { DevframeMessageLevel } +export { DevframeMessagesClient } +export { DevframeMessagesHost } +export { DevframeNodeRpcSession } +export { DevframeRpcClientFunctions } +export { DevframeRpcServerFunctions } +export { DevframeRpcSharedStates } +export { DevframeServerCommandEntry } +export { DevframeServerCommandInput } +export { DevframeTerminalSession } +export { DevframeTerminalSessionBase } +export { DevframeTerminalsHost } +export { DevframeTerminalStatus } +export { DevframeViewAction } +export { DevframeViewBuiltin } +export { DevframeViewCustomRender } +export { DevframeViewHost } +export { DevframeViewIframe } +export { DevframeViewJsonRender } +export { DevframeViewLauncher } +export { DevframeViewLauncherStatus } +export { EntriesToObject } +export { EventEmitter } +export { EventsMap } +export { EventUnsubscribe } +export { JsonRenderElement } +export { JsonRenderer } +export { JsonRenderSpec } +export { PartialWithoutId } +export { RemoteConnectionInfo } +export { RemoteDockOptions } +export { RpcBroadcastOptions } +export { RpcDefinitionsFilter } +export { RpcDefinitionsToFunctions } +export { RpcFunctionsHost } +export { RpcSharedStateGetOptions } +export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } +export { Thenable } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.js new file mode 100644 index 0000000..39ad9f9 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.js @@ -0,0 +1,4 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/hub/types` + */ +/* no exports */ \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts index 256156c..c63ad99 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts @@ -3,7 +3,7 @@ */ // #region Interfaces export interface CreateEmbeddedOptions { - ctx: DevToolsNodeContext; + ctx: DevframeNodeContext; } // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index c0acee9..651268c 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -2,40 +2,40 @@ * Generated by tsnapi — public API snapshot of `devframe/client` */ // #region Interfaces -export interface DevToolsRpcClient { +export interface DevframeRpcClient { events: EventEmitter; readonly isTrusted: boolean | null; readonly connectionMeta: ConnectionMeta; ensureTrusted: (_?: number) => Promise; requestTrust: () => Promise; requestTrustWithToken: (_: string) => Promise; - call: DevToolsRpcClientCall; - callEvent: DevToolsRpcClientCallEvent; - callOptional: DevToolsRpcClientCallOptional; - client: DevToolsClientRpcHost; + call: DevframeRpcClientCall; + callEvent: DevframeRpcClientCallEvent; + callOptional: DevframeRpcClientCallOptional; + client: DevframeClientRpcHost; sharedState: RpcSharedStateHost; streaming: RpcStreamingClientHost; cacheManager: RpcCacheManager; } -export interface DevToolsRpcClientMode { +export interface DevframeRpcClientMode { readonly isTrusted: boolean; - ensureTrusted: DevToolsRpcClient['ensureTrusted']; - requestTrust: DevToolsRpcClient['requestTrust']; - requestTrustWithToken: DevToolsRpcClient['requestTrustWithToken']; - call: DevToolsRpcClient['call']; - callEvent: DevToolsRpcClient['callEvent']; - callOptional: DevToolsRpcClient['callOptional']; + ensureTrusted: DevframeRpcClient['ensureTrusted']; + requestTrust: DevframeRpcClient['requestTrust']; + requestTrustWithToken: DevframeRpcClient['requestTrustWithToken']; + call: DevframeRpcClient['call']; + callEvent: DevframeRpcClient['callEvent']; + callOptional: DevframeRpcClient['callOptional']; } -export interface DevToolsRpcClientOptions { +export interface DevframeRpcClientOptions { connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string; wsOptions?: Partial; - rpcOptions?: Partial>; + rpcOptions?: Partial>; cacheOptions?: boolean | Partial; } -export interface DevToolsRpcContext { - readonly rpc: DevToolsRpcClient; +export interface DevframeRpcContext { + readonly rpc: DevframeRpcClient; } export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; @@ -50,17 +50,17 @@ export interface StreamingSubscribeOptions { // #endregion // #region Types -export type DevToolsClientRpcHost = RpcFunctionsCollector; -export type DevToolsRpcClientCall = BirpcReturn['$call']; -export type DevToolsRpcClientCallEvent = BirpcReturn['$callEvent']; -export type DevToolsRpcClientCallOptional = BirpcReturn['$callOptional']; +export type DevframeClientRpcHost = RpcFunctionsCollector; +export type DevframeRpcClientCall = BirpcReturn['$call']; +export type DevframeRpcClientCallEvent = BirpcReturn['$callEvent']; +export type DevframeRpcClientCallOptional = BirpcReturn['$callOptional']; // #endregion // #region Functions -export declare function createRpcStreamingClientHost(_: DevToolsRpcClient): RpcStreamingClientHost; -export declare function getDevToolsRpcClient(_?: DevToolsRpcClientOptions): Promise; +export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; +export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; // #endregion // #region Variables -export declare const connectDevframe: typeof getDevToolsRpcClient; +export declare const connectDevframe: typeof getDevframeRpcClient; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 55665f5..948fd17 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -3,7 +3,7 @@ */ // #region Functions export function createRpcStreamingClientHost(_) {} -export async function getDevToolsRpcClient(_) {} +export async function getDevframeRpcClient(_) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 089b973..de0b4fc 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -2,13 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables -export declare const DEVTOOLS_CONNECTION_META_FILENAME: string; -export declare const DEVTOOLS_DIRNAME: string; -export declare const DEVTOOLS_DOCK_IMPORTS_FILENAME: string; -export declare const DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID: string; -export declare const DEVTOOLS_MOUNT_PATH: string; -export declare const DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH: string; -export declare const DEVTOOLS_RPC_DUMP_DIRNAME: string; -export declare const DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME: string; +export declare const DEVFRAME_CONNECTION_META_FILENAME: string; +export declare const DEVFRAME_DIRNAME: string; +export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; +export declare const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID: string; +export declare const DEVFRAME_MOUNT_PATH: string; +export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; +export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; +export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; export declare const REMOTE_CONNECTION_KEY: string; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index bc40e7f..2146d20 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -2,13 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables -export var DEVTOOLS_CONNECTION_META_FILENAME /* const */ -export var DEVTOOLS_DIRNAME /* const */ -export var DEVTOOLS_DOCK_IMPORTS_FILENAME /* const */ -export var DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID /* const */ -export var DEVTOOLS_MOUNT_PATH /* const */ -export var DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH /* const */ -export var DEVTOOLS_RPC_DUMP_DIRNAME /* const */ -export var DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME /* const */ +export var DEVFRAME_CONNECTION_META_FILENAME /* const */ +export var DEVFRAME_DIRNAME /* const */ +export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ +export var DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID /* const */ +export var DEVFRAME_MOUNT_PATH /* const */ +export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ +export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ +export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ export var REMOTE_CONNECTION_KEY /* const */ // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 2bb3743..6797571 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe` */ // #region Variables -export declare const defineRpcFunction: (definition: RpcFunctionDefinition) => RpcFunctionDefinition; +export declare const defineRpcFunction: (definition: RpcFunctionDefinition) => RpcFunctionDefinition; // #endregion // #region Other @@ -15,28 +15,28 @@ export { AgentTool } export { AgentToolInput } export { ConnectionMeta } export { defineDevframe } +export { DevframeAgentHost } +export { DevframeAgentHostEvents } export { DevframeBrowserContext } +export { DevframeCapabilities } export { DevframeCliOptions } +export { DevframeDefineDiagnosticsOptions } export { DevframeDefinition } export { DevframeDeploymentKind } +export { DevframeDiagnosticsDefinition } +export { DevframeDiagnosticsHost } +export { DevframeDiagnosticsLogger } +export { DevframeHost } +export { DevframeNodeContext } +export { DevframeNodeRpcSession } +export { DevframeNodeRpcSessionMeta } +export { DevframeRpcClientFunctions } +export { DevframeRpcServerFunctions } +export { DevframeRpcSharedStates } export { DevframeRuntime } export { DevframeSetupInfo } export { DevframeSpaOptions } -export { DevToolsAgentHost } -export { DevToolsAgentHostEvents } -export { DevToolsCapabilities } -export { DevToolsDefineDiagnosticsOptions } -export { DevToolsDiagnosticsDefinition } -export { DevToolsDiagnosticsHost } -export { DevToolsDiagnosticsLogger } -export { DevToolsHost } -export { DevToolsNodeContext } -export { DevToolsNodeRpcSession } -export { DevToolsNodeRpcSessionMeta } -export { DevToolsRpcClientFunctions } -export { DevToolsRpcServerFunctions } -export { DevToolsRpcSharedStates } -export { DevToolsViewHost } +export { DevframeViewHost } export { EntriesToObject } export { EventEmitter } export { EventsMap } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 7f11684..97087d9 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node` */ // #region Interfaces -export interface CreateH3DevToolsHostOptions { +export interface CreateH3DevframeHostOptions { app?: unknown; origin: string; mount?: (_: string, _: string) => void | Promise; @@ -13,7 +13,7 @@ export interface CreateHostContextOptions { cwd: string; workspaceRoot?: string; mode: 'dev' | 'build'; - host: DevToolsHost; + host: DevframeHost; builtinRpcDeclarations?: readonly RpcFunctionDefinitionAny[]; } export interface CreateStorageOptions { @@ -25,13 +25,13 @@ export interface CreateStorageOptions { // #endregion // #region Classes -export declare class DevToolsAgentHost implements DevToolsAgentHost$1 { - readonly context: DevToolsNodeContext; - readonly events: EventEmitter; +export declare class DevframeAgentHost implements DevframeAgentHost$1 { + readonly context: DevframeNodeContext; + readonly events: EventEmitter; private readonly tools; private readonly resources; private _rpcUnsubscribe; - constructor(_: DevToolsNodeContext); + constructor(_: DevframeNodeContext); registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; registerResource(_: AgentResourceInput): AgentHandle; @@ -48,39 +48,39 @@ export declare class DevToolsAgentHost implements DevToolsAgentHost$1 { private _findRpcDefinition; private _coercePositionalArgs; } -export declare class DevToolsDiagnosticsHost implements DevToolsDiagnosticsHost$1 { - readonly context: DevToolsNodeContext; +export declare class DevframeDiagnosticsHost implements DevframeDiagnosticsHost$1 { + readonly context: DevframeNodeContext; private _registry; - readonly logger: DevToolsDiagnosticsLogger; - readonly defineDiagnostics: DevToolsDiagnosticsHost$1['defineDiagnostics']; - constructor(_: DevToolsNodeContext, _?: Array>); + readonly logger: DevframeDiagnosticsLogger; + readonly defineDiagnostics: DevframeDiagnosticsHost$1['defineDiagnostics']; + constructor(_: DevframeNodeContext, _?: Array>); register(_: Record): void; } -export declare class DevToolsViewHost implements DevToolsViewHost$1 { - readonly context: DevToolsNodeContext; +export declare class DevframeViewHost implements DevframeViewHost$1 { + readonly context: DevframeNodeContext; buildStaticDirs: { baseUrl: string; distDir: string; }[]; - constructor(_: DevToolsNodeContext); + constructor(_: DevframeNodeContext); hostStatic(_: string, _: string): void; } -export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHost$1 { - _rpcGroup: BirpcGroup; - _asyncStorage: AsyncLocalStorage; - constructor(_: DevToolsNodeContext); +export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHost$1 { + _rpcGroup: BirpcGroup; + _asyncStorage: AsyncLocalStorage; + constructor(_: DevframeNodeContext); sharedState: RpcSharedStateHost; streaming: RpcStreamingHost; - _emitSessionDisconnected(_: DevToolsNodeRpcSessionMeta): void; - invokeLocal>(_: T, ..._: Args): Promise>>; - broadcast>(_: RpcBroadcastOptions): Promise; - getCurrentRpcSession(): DevToolsNodeRpcSession | undefined; + _emitSessionDisconnected(_: DevframeNodeRpcSessionMeta): void; + invokeLocal>(_: T, ..._: Args): Promise>>; + broadcast>(_: RpcBroadcastOptions): Promise; + getCurrentRpcSession(): DevframeNodeRpcSession | undefined; } // #endregion // #region Functions -export declare function createH3DevToolsHost(_: CreateH3DevToolsHostOptions): DevToolsHost; -export declare function createHostContext(_: CreateHostContextOptions): Promise; +export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; +export declare function createHostContext(_: CreateHostContextOptions): Promise; export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): RpcSharedStateHost; export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost; export declare function createStorage(_: CreateStorageOptions): SharedState; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index 09aade8..0cdf553 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -7,14 +7,14 @@ export function normalizeHttpServerUrl(_, _) {} // #endregion // #region Other -export { createH3DevToolsHost } +export { createH3DevframeHost } export { createHostContext } export { createRpcSharedStateServerHost } export { createRpcStreamingServerHost } export { createStorage } -export { DevToolsAgentHost } -export { DevToolsDiagnosticsHost } -export { DevToolsViewHost } +export { DevframeAgentHost } +export { DevframeDiagnosticsHost } +export { DevframeViewHost } export { RpcFunctionsHost } export { startHttpAndWs } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index b618952..86bb867 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -4,7 +4,7 @@ // #region Interfaces export interface PendingAuthRequest { clientAuthToken: string; - session: DevToolsNodeRpcSession; + session: DevframeNodeRpcSession; ua: string; origin: string; resolve: (_: { @@ -21,7 +21,7 @@ export declare function consumeTempAuthToken(_: string, _: SharedState; -export declare function revokeAuthToken(_: DevToolsNodeContext, _: SharedState, _: string): Promise; +export declare function revokeActiveConnectionsForToken(_: DevframeNodeContext, _: string): Promise; +export declare function revokeAuthToken(_: DevframeNodeContext, _: SharedState, _: string): Promise; export declare function setPendingAuth(_: PendingAuthRequest | null): void; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts similarity index 89% rename from tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts rename to tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts index 574165c..f9dded1 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts @@ -1,5 +1,5 @@ /** - * Generated by tsnapi — public API snapshot of `devframe/node/internal` + * Generated by tsnapi — public API snapshot of `devframe/node/hub-internals` */ // #region Functions export declare function normalizeBasePath(_: string): string; @@ -7,7 +7,7 @@ export declare function resolveBasePath(_: DevframeDefinition, _: DevframeDeploy // #endregion // #region Other -export { DevToolsInternalContext } +export { DevframeInternalContext } export { getInternalContext } export { InternalAnonymousAuthStorage } export { internalContextMap } diff --git a/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.js new file mode 100644 index 0000000..e958f15 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.js @@ -0,0 +1,9 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node/hub-internals` + */ +// #region Other +export { getInternalContext } +export { internalContextMap } +export { normalizeBasePath } +export { resolveBasePath } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js deleted file mode 100644 index 235aba6..0000000 --- a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe/node/internal` - */ -// #region Functions -export function getInternalContext(_) {} -// #endregion - -// #region Variables -export var internalContextMap /* const */ -// #endregion - -// #region Other -export { normalizeBasePath } -export { resolveBasePath } -// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts index fa54ae2..a98666c 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts @@ -3,6 +3,6 @@ */ // #region Other export { attachWsRpcTransport } -export { DevToolsNodeRpcSessionMeta } +export { DevframeNodeRpcSessionMeta } export { WsRpcTransportOptions } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index f6e3258..cd62e31 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -11,28 +11,28 @@ export { AgentTool } export { AgentToolInput } export { ConnectionMeta } export { defineDevframe } +export { DevframeAgentHost } +export { DevframeAgentHostEvents } export { DevframeBrowserContext } +export { DevframeCapabilities } export { DevframeCliOptions } +export { DevframeDefineDiagnosticsOptions } export { DevframeDefinition } export { DevframeDeploymentKind } +export { DevframeDiagnosticsDefinition } +export { DevframeDiagnosticsHost } +export { DevframeDiagnosticsLogger } +export { DevframeHost } +export { DevframeNodeContext } +export { DevframeNodeRpcSession } +export { DevframeNodeRpcSessionMeta } +export { DevframeRpcClientFunctions } +export { DevframeRpcServerFunctions } +export { DevframeRpcSharedStates } export { DevframeRuntime } export { DevframeSetupInfo } export { DevframeSpaOptions } -export { DevToolsAgentHost } -export { DevToolsAgentHostEvents } -export { DevToolsCapabilities } -export { DevToolsDefineDiagnosticsOptions } -export { DevToolsDiagnosticsDefinition } -export { DevToolsDiagnosticsHost } -export { DevToolsDiagnosticsLogger } -export { DevToolsHost } -export { DevToolsNodeContext } -export { DevToolsNodeRpcSession } -export { DevToolsNodeRpcSessionMeta } -export { DevToolsRpcClientFunctions } -export { DevToolsRpcServerFunctions } -export { DevToolsRpcSharedStates } -export { DevToolsViewHost } +export { DevframeViewHost } export { EntriesToObject } export { EventEmitter } export { EventsMap } diff --git a/tsconfig.base.json b/tsconfig.base.json index ae47da5..1fac0a9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,8 +33,8 @@ "devframe/node/auth": [ "./packages/devframe/src/node/auth/index.ts" ], - "devframe/node/internal": [ - "./packages/devframe/src/node/internal/index.ts" + "devframe/node/hub-internals": [ + "./packages/devframe/src/node/hub-internals/index.ts" ], "devframe/node": [ "./packages/devframe/src/node/index.ts" @@ -99,6 +99,21 @@ "devframe/adapters/mcp": [ "./packages/devframe/src/adapters/mcp/index.ts" ], + "@devframes/hub/client": [ + "./packages/hub/src/client/index.ts" + ], + "@devframes/hub/constants": [ + "./packages/hub/src/constants.ts" + ], + "@devframes/hub/node": [ + "./packages/hub/src/node/index.ts" + ], + "@devframes/hub/types": [ + "./packages/hub/src/types/index.ts" + ], + "@devframes/hub": [ + "./packages/hub/src/index.ts" + ], "@devframes/nuxt/runtime/plugin.client": [ "./packages/nuxt/src/runtime/plugin.client.ts" ], diff --git a/turbo.json b/turbo.json index 1b3617b..5eb83cf 100644 --- a/turbo.json +++ b/turbo.json @@ -6,10 +6,25 @@ "outputLogs": "new-only", "outputs": ["dist/**"] }, + "@devframes/hub#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "@devframes/nuxt#build": { "outputLogs": "new-only", "outputs": ["dist/**"] }, + "minimal-vite-devframe-hub#build": { + "outputLogs": "new-only", + "dependsOn": ["@devframes/hub#build", "devframe#build"], + "outputs": ["dist/**"] + }, + "minimal-next-devframe-hub#build": { + "outputLogs": "new-only", + "dependsOn": ["@devframes/hub#build", "devframe#build"], + "outputs": ["dist/**"] + }, "files-inspector-example#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], diff --git a/vitest.config.ts b/vitest.config.ts index 391bf30..79b7cdc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,12 +1,18 @@ import { defineConfig } from 'vitest/config' +import { alias } from './alias' export default defineConfig({ + resolve: { + alias, + }, test: { projects: [ 'packages/devframe', + 'packages/hub', 'examples/files-inspector', 'examples/streaming-chat', 'examples/next-runtime-snapshot', + 'examples/minimal-next-devframe-hub', { test: { name: 'tests',