From 3b9c175d095fd109812d7350342d04a09b9ae5fd Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 12 Aug 2026 07:34:07 +0000 Subject: [PATCH 1/2] refactor!: replace startHttpAndWs and mountDevframe with initHub/initDevframe and ctx.install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the public `startHttpAndWs` API in favor of `initDevframe`/`initHub`, which now own the HTTP+WS binding internally (via the instance shell). Add an opt-in `register` flag to both factories so they publish to the instance registry via a dynamic import — `createDevServer` delegates to it. Replace the free `mountDevframe` function with a `ctx.install(devframe)` method on the hub context; `initHub`'s `devframes` list and every host go through it. Convert the legacy manual hosts and simplify the hub examples accordingly. BREAKING CHANGE: `startHttpAndWs` / `StartHttpAndWsOptions` and `mountDevframe` / `MountDevframeOptions` are removed. Use `initDevframe` / `initHub` (with `register` for registry publishing) and `ctx.install` instead. --- docs/adapters/initiate.md | 2 +- docs/errors/DF0007.md | 2 +- docs/errors/DF0036.md | 13 +- docs/errors/DF0052.md | 6 +- docs/errors/DF0056.md | 2 +- docs/errors/DF8002.md | 2 +- docs/errors/DF8105.md | 6 +- docs/errors/DF8106.md | 4 +- docs/guide/client-context.md | 6 +- docs/guide/hub-initiate.md | 2 +- docs/guide/hub.md | 12 +- docs/guide/migration-0.9.md | 2 +- docs/guide/security.md | 10 +- docs/helpers/interactive-auth.md | 12 +- docs/helpers/next.md | 39 +-- docs/helpers/vite-bridge.md | 2 +- docs/plugins/a11y.md | 2 +- .../a11y-messages-playground/package.json | 1 - .../src/a11y-messages-playground.ts | 141 +++------ examples/files-inspector/tests/_utils.ts | 5 +- .../src/client/devframe/demo-devframe-b.ts | 2 +- .../src/client/devframe/demo-devframe.ts | 4 +- .../src/client/devframe/next-devframe-hub.ts | 39 +-- examples/hub-vite/src/devframe-b.ts | 2 +- examples/hub-vite/src/devframe.ts | 6 +- examples/hub-vite/src/vite-devframe-hub.ts | 61 ++-- .../next-runtime-snapshot/tests/_utils.ts | 5 +- examples/streaming-chat/tests/_utils.ts | 5 +- package.json | 2 + .../src/adapters/__tests__/dev.test.ts | 2 +- packages/devframe/src/adapters/_shared.ts | 2 +- packages/devframe/src/adapters/dev.ts | 43 +-- packages/devframe/src/adapters/initiate.ts | 23 +- .../adapters/mcp/__tests__/mcp-http.test.ts | 2 +- packages/devframe/src/constants.ts | 2 +- packages/devframe/src/internal/index.ts | 13 +- .../src/node/__tests__/server.test.ts | 162 ---------- packages/devframe/src/node/auth/handler.ts | 2 +- packages/devframe/src/node/host-functions.ts | 2 +- packages/devframe/src/node/index.ts | 4 +- packages/devframe/src/node/instance-shell.ts | 246 ++++++++++++++- packages/devframe/src/node/rpc-core.ts | 20 +- packages/devframe/src/node/server.ts | 291 ------------------ .../__tests__/interactive-auth.test.ts | 4 +- .../devframe/src/recipes/interactive-auth.ts | 2 +- packages/devframe/src/types/devframe.ts | 8 +- packages/devframe/src/types/host.ts | 2 +- packages/devframe/src/types/rpc.ts | 2 +- .../hub/src/node/__tests__/context.test.ts | 6 +- ...frame.test.ts => install-devframe.test.ts} | 43 ++- packages/hub/src/node/context.ts | 13 +- packages/hub/src/node/index.ts | 2 +- packages/hub/src/node/initiate.ts | 27 +- ...{mount-devframe.ts => install-devframe.ts} | 22 +- plugins/a11y/demo/server.mjs | 30 +- plugins/a11y/tests/_utils.ts | 5 +- plugins/assets/test/_utils.ts | 5 +- plugins/data-inspector/src/inject/index.ts | 28 +- plugins/git/test/_utils.ts | 5 +- plugins/inspect/test/_utils.ts | 5 +- plugins/messages/README.md | 3 +- plugins/messages/src/diagnostics.ts | 2 +- plugins/messages/test/_utils.ts | 5 +- plugins/og/test/_utils.ts | 5 +- plugins/terminals/test/_utils.ts | 5 +- pnpm-lock.yaml | 9 +- storybook/README.md | 8 +- storybook/src/hub.ts | 247 +++++++-------- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 1 + .../@devframes/hub/initiate.snapshot.d.ts | 3 +- .../tsnapi/@devframes/hub/node.snapshot.d.ts | 3 +- .../tsnapi/@devframes/hub/node.snapshot.js | 1 - .../tsnapi/devframe/initiate.snapshot.d.ts | 1 + .../tsnapi/devframe/internal.snapshot.d.ts | 91 +----- .../tsnapi/devframe/internal.snapshot.js | 2 +- tests/helpers/serve-test-context.ts | 105 +++++++ 76 files changed, 826 insertions(+), 1085 deletions(-) delete mode 100644 packages/devframe/src/node/__tests__/server.test.ts delete mode 100644 packages/devframe/src/node/server.ts rename packages/hub/src/node/__tests__/{mount-devframe.test.ts => install-devframe.test.ts} (77%) rename packages/hub/src/node/{mount-devframe.ts => install-devframe.ts} (83%) create mode 100644 tests/helpers/serve-test-context.ts diff --git a/docs/adapters/initiate.md b/docs/adapters/initiate.md index f4031678..c7c8b211 100644 --- a/docs/adapters/initiate.md +++ b/docs/adapters/initiate.md @@ -116,7 +116,7 @@ Fetch handlers hand over `Request`s, so the RPC socket needs a binding of its ow 3. **`ws: { sidecar: true }`** — a side-car server on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). 4. **The host's own upgrades** — with none of the above, the socket waits for the host to hand upgrade events over: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach function), and `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you already own. This is the tier for hosts whose server exists only after the instance does, and it builds the transport lazily — an instance nobody attaches costs nothing. -`ws.url` controls the *advertisement* instead: the browser dials it verbatim. On its own it means an external server owns the transport and its auth (wire the instance's `context` into that server with `startHttpAndWs`); alongside a local binding it overrides only what is advertised — the tunnel pattern, where a relay forwards to the socket bound here. +`ws.url` controls the *advertisement* instead: the browser dials it verbatim. On its own it means an external server owns the transport and its auth (wire the instance's `context` into that server by composing `createContextRpcServer` with a WS transport); alongside a local binding it overrides only what is advertised — the tunnel pattern, where a relay forwards to the socket bound here. Whichever combination is active, `__connection.json` describes it and the browser client follows. Asking a configured instance to also take over host upgrades reports `DF0055` (a local binding already owns the socket) or `DF0056` (`ws.url` handed it to someone else). diff --git a/docs/errors/DF0007.md b/docs/errors/DF0007.md index 09bdfe55..e6ee3bed 100644 --- a/docs/errors/DF0007.md +++ b/docs/errors/DF0007.md @@ -10,7 +10,7 @@ outline: deep ## Cause -`getCurrentRpcSession()` was called outside the RPC dispatch context. Usually indicates the RPC server hasn't been composed with `startHttpAndWs` or the caller is running before the async context is established. +`getCurrentRpcSession()` was called outside the RPC dispatch context. Usually indicates the RPC server hasn't been composed with the context transport binding (`createContextRpcServer`) or the caller is running before the async context is established. ## Fix diff --git a/docs/errors/DF0036.md b/docs/errors/DF0036.md index 6f59d5ea..126ff3b8 100644 --- a/docs/errors/DF0036.md +++ b/docs/errors/DF0036.md @@ -10,17 +10,16 @@ outline: deep ## Cause -`startHttpAndWs` was configured with an `authorize` gate (either directly, or via a [`DevframeAuthHandler`](../guide/security) passed as `auth`) and the calling session hasn't satisfied it — the call is neither to an `anonymous:`-prefixed method (see `isAnonymousRpcMethod`) nor made by a trusted session. +The RPC server was configured with an `authorize` gate (either directly, or via a [`DevframeAuthHandler`](../guide/security) passed as `auth`) and the calling session hasn't satisfied it — the call is neither to an `anonymous:`-prefixed method (see `isAnonymousRpcMethod`) nor made by a trusted session. ## Example ```ts -import { startHttpAndWs } from 'devframe/node' -import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { createDevServer } from 'devframe/adapters/dev' -const auth = createInteractiveAuth(ctx) - -await startHttpAndWs({ context: ctx, port: 9999, auth }) +// `auth` defaults to devframe's interactive gate; `initDevframe` / `initHub` +// gate hosted instances the same way through their own `auth` option. +await createDevServer(def) // A browser that hasn't completed the handshake yet can still reach the // handshake methods themselves… @@ -38,4 +37,4 @@ await client.call('some-plugin:do-something') // ✗ throws DF0036 ## Source -- [`packages/devframe/src/node/server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/server.ts) — `startHttpAndWs`'s resolver throws this when `authorize`/`auth.authorize` rejects a call. +- [`packages/devframe/src/node/rpc-core.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/rpc-core.ts) — `createContextRpcServer`'s resolver throws this when `authorize`/`auth.authorize` rejects a call. diff --git a/docs/errors/DF0052.md b/docs/errors/DF0052.md index 9922ef89..16b19f85 100644 --- a/docs/errors/DF0052.md +++ b/docs/errors/DF0052.md @@ -10,13 +10,13 @@ outline: deep ## Cause -`startHttpAndWs` tried to bind the HTTP server it owns to `host:port` and the underlying `listen()` call failed — most commonly `EADDRINUSE` (another process, often a previous devframe instance, is already bound to that port) or `EACCES` (insufficient permissions, typically a privileged port). The WS RPC transport is torn down before this error surfaces, so nothing is leaked. +The instance's side-car / shared-server transport binding tried to bind the HTTP server it owns to `host:port` and the underlying `listen()` call failed — most commonly `EADDRINUSE` (another process, often a previous devframe instance, is already bound to that port) or `EACCES` (insufficient permissions, typically a privileged port). The WS RPC transport is torn down before this error surfaces, so nothing is leaked. ## Example ```ts // A previous instance is still bound to 4096: -// await startHttpAndWs({ context, host: 'localhost', port: 4096 }) → DF0052 +// await createDevServer(def, { host: 'localhost', port: 4096 }) → DF0052 ``` ## Fix @@ -26,4 +26,4 @@ outline: deep ## Source -- [`packages/devframe/src/node/server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/server.ts) — `startHttpAndWs()` throws this when its owned HTTP server's `listen()` fails. +- [`packages/devframe/src/node/instance-shell.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-shell.ts) — the instance shell's HTTP+WS binding throws this when its owned HTTP server's `listen()` fails. diff --git a/docs/errors/DF0056.md b/docs/errors/DF0056.md index 03c23ede..c5260e05 100644 --- a/docs/errors/DF0056.md +++ b/docs/errors/DF0056.md @@ -32,7 +32,7 @@ const tunnelled = initDevframe(def, { ## Fix -Drop `ws.url` to have the instance serve the socket, or pair it with `server` / `ws.port` / `ws.sidecar` for the tunnel pattern — a local binding that the advertised relay forwards to. To serve RPC from a server you wire yourself, run `startHttpAndWs({ context, server, path })` against the instance's `context` and keep `ws.url` pointed at it. +Drop `ws.url` to have the instance serve the socket, or pair it with `server` / `ws.port` / `ws.sidecar` for the tunnel pattern — a local binding that the advertised relay forwards to. To serve RPC from a server you wire yourself, compose `createContextRpcServer` (`devframe/internal`) with a WS transport (`devframe/rpc/transports/ws-server`) against the instance's `context` and keep `ws.url` pointed at it. ## Source diff --git a/docs/errors/DF8002.md b/docs/errors/DF8002.md index 49331b8e..436ea37a 100644 --- a/docs/errors/DF8002.md +++ b/docs/errors/DF8002.md @@ -23,7 +23,7 @@ initHub({ base: '/__devframes/', devframes: [git] }) // ✓ Good — bring your own context: const ctx = await createHubContext({ host: myHost, cwd }) -await mountDevframe(ctx, git) +await ctx.install(git) initHub({ base: '/__devframes/', context: ctx }) ``` diff --git a/docs/errors/DF8105.md b/docs/errors/DF8105.md index 3c2cebed..4a1f278f 100644 --- a/docs/errors/DF8105.md +++ b/docs/errors/DF8105.md @@ -10,7 +10,7 @@ outline: deep ## Cause -`mountDevframe(ctx, def)` was called with a devframe whose `id` already belongs to another devframe mounted on the same hub. Devframes are deduplicated by `id`, and the definition's `duplicationStrategy` is `'warn'` (the default) or `'throw'`. +`ctx.install(def)` was called with a devframe whose `id` already belongs to another devframe mounted on the same hub. Devframes are deduplicated by `id`, and the definition's `duplicationStrategy` is `'warn'` (the default) or `'throw'`. ## Fix @@ -21,8 +21,8 @@ Set `duplicationStrategy` on the definition to choose how duplicates are handled - `'throw'` — surface duplicates as a thrown error. - `'duplicate'` — let every instance coexist under a disambiguated dock id (`my-tool`, `my-tool-2`, …). -Otherwise, remove the redundant `mountDevframe` call so each devframe is mounted once. +Otherwise, remove the redundant `ctx.install` call (or the duplicate `devframes` entry) so each devframe is mounted once. ## Source -- [`packages/hub/src/node/mount-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/mount-devframe.ts) — `mountDevframe()` emits this when a devframe sharing an already-mounted `id` is mounted and the strategy is not `'duplicate'`. +- [`packages/hub/src/node/install-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/install-devframe.ts) — `ctx.install()` emits this when a devframe sharing an already-mounted `id` is installed and the strategy is not `'duplicate'`. diff --git a/docs/errors/DF8106.md b/docs/errors/DF8106.md index ee3d8bc4..5e7a5d00 100644 --- a/docs/errors/DF8106.md +++ b/docs/errors/DF8106.md @@ -10,7 +10,7 @@ outline: deep ## Cause -A mounted devframe's SPA loads in an iframe at its own base (e.g. `/__terminals/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base to discover the RPC/WebSocket endpoint. `mountDevframe` serves that file at each base by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`. +A mounted devframe's SPA loads in an iframe at its own base (e.g. `/__terminals/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base to discover the RPC/WebSocket endpoint. `ctx.install` serves that file at each base by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`. This diagnostic is reported when a devframe with a servable `cli.distDir` is mounted on a `DevframeHost` that does not implement `mountConnectionMeta`. The SPA's `./__connection.json` fetch then falls through to the host's HTML fallback, so the SPA cannot discover the endpoint and its panel stays empty or stuck loading — previously a silent failure. @@ -35,4 +35,4 @@ A static-snapshot host that bakes `__connection.json` into its served files can ## Source -- [`packages/hub/src/node/mount-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/mount-devframe.ts) — `mountDevframe()` emits this when a devframe with a servable `distDir` is mounted on a host lacking `mountConnectionMeta`. +- [`packages/hub/src/node/install-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/install-devframe.ts) — `ctx.install()` emits this when a devframe with a servable `distDir` is installed on a host lacking `mountConnectionMeta`. diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 5ecb6b87..9a9f8c93 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -139,7 +139,7 @@ A script that fails to import is logged and retried on the next dock update. Build the script as a single self-contained ES module — it loads outside any chunk graph or import map. Attach it when mounting the devframe: ```ts -await mountDevframe(ctx, myDevframe, { +await ctx.install(myDevframe, { dock: { clientScript: { importFrom: `/@fs/${myAgentBundlePath}` } }, }) ``` @@ -152,14 +152,14 @@ The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client scri ## Iframe panels -Dock iframes are their own documents, so they connect themselves instead of reading the host page's context: the panel SPA calls `connectDevframe()`, which discovers `./__connection.json` relative to its own base — `mountDevframe` serves the hub's connection meta under every dock base for exactly this. The client script (host page) and the iframe panel then share the server through RPC and shared state, or a same-origin `BroadcastChannel` when the loop must survive static builds. +Dock iframes are their own documents, so they connect themselves instead of reading the host page's context: the panel SPA calls `connectDevframe()`, which discovers `./__connection.json` relative to its own base — `ctx.install` serves the hub's connection meta under every dock base for exactly this. The client script (host page) and the iframe panel then share the server through RPC and shared state, or a same-origin `BroadcastChannel` when the loop must survive static builds. ## Shared-iframe soft navigation A tool with many internal views — Nuxt DevTools' tabs, say — can surface each view as its own hub dock while they all share **one** live iframe, switching between them with client-side (soft) navigation instead of reloading. One iframe dock is the **anchor**: it owns a `frameId` and opts in with `subTabs`. ```ts -await mountDevframe(ctx, nuxtDevtools, { +await ctx.install(nuxtDevtools, { dock: { frameId: 'nuxt-devtools', subTabs: { protocol: 'postmessage' } }, }) ``` diff --git a/docs/guide/hub-initiate.md b/docs/guide/hub-initiate.md index e3d61a8a..3c4e1275 100644 --- a/docs/guide/hub-initiate.md +++ b/docs/guide/hub-initiate.md @@ -82,7 +82,7 @@ A devframe's SPA and RPC client code are byte-identical in both cases — that i ## Bring your own context -Hosts that assemble `createHubContext` + `mountDevframe` themselves (with their own `DevframeHost` serving the frames) pass the finished context instead of a `devframes` list: +Hosts that assemble `createHubContext` + `ctx.install` themselves (with their own `DevframeHost` serving the frames) pass the finished context instead of a `devframes` list: ```ts const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx }) diff --git a/docs/guide/hub.md b/docs/guide/hub.md index eb9d7e96..884d8461 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -119,20 +119,20 @@ This is what lets a downstream analyzer spawn a `vite build`, show its progress ## 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)`: +`ctx.install(def)` is the framework-neutral primitive that registers any `DevframeDefinition` as a dock and runs its `setup(ctx)`. It's the imperative counterpart to `initHub`'s declarative `devframes` list: ```ts -import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { createHubContext } from '@devframes/hub/node' const ctx = await createHubContext({ cwd, host, mode: 'dev' }) -await mountDevframe(ctx, myDevframe) +await ctx.install(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`. +Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `ctx.install`. ### Connecting embedded SPAs -A mounted devframe's SPA loads in an iframe at its base (`/__/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base. `mountDevframe` serves it there by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`, so the SPA discovers the RPC/WS endpoint directly. Implement `mountConnectionMeta` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base: +A mounted devframe's SPA loads in an iframe at its base (`/__/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base. `ctx.install` serves it there by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`, so the SPA discovers the RPC/WS endpoint directly. Implement `mountConnectionMeta` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base: ```ts const host: DevframeHost = { @@ -165,7 +165,7 @@ const defs = await Promise.all( ).then(mods => mods.map(m => m.default)) for (const def of defs) - await mountDevframe(ctx, def) + await ctx.install(def) ``` Each mounted SPA is served at `/__/` and references its assets relatively (`./_next/…`, `./assets/…`). Disable the bundler's trailing-slash redirect so those paths resolve under the mount base: diff --git a/docs/guide/migration-0.9.md b/docs/guide/migration-0.9.md index 7fb2b57c..271e6683 100644 --- a/docs/guide/migration-0.9.md +++ b/docs/guide/migration-0.9.md @@ -152,7 +152,7 @@ The low-level primitives shared between `devframe` and its first-party integrati | `registerDevframeInstance` / `listLiveDevframeInstances` (+ `DevframeInstanceRecord`, `DevframeInstanceRegistration`) | `devframe/node` | `devframe/internal` | | `normalizeHttpServerUrl` | `devframe/node` | `devframe/internal` | -A host that stands up its own server composes from `devframe/internal` — `createH3DevframeHost` for the node `DevframeHost`, `startHttpAndWs` (or `createContextRpcServer` + `devframe/rpc/server` + `devframe/rpc/transports/*`) to bind a transport — plus `devframe/node`'s `createHostContext` and `devframe/node/hub-internals`. This is the path `@devframes/hub`'s `initHub` takes. A custom host advertises itself with `registerDevframeInstance`, and a devtool enumerates running instances with `listLiveDevframeInstances`. Application code should prefer the adapters and `devframe/initiate`. +A host that stands up its own server composes from `devframe/internal` — `createH3DevframeHost` for the node `DevframeHost`, `createContextRpcServer` + `devframe/rpc/transports/*` to bind a transport — plus `devframe/node`'s `createHostContext` and `devframe/node/hub-internals`. This is the path `@devframes/hub`'s `initHub` takes (in later releases `startHttpAndWs` was removed in favor of `initDevframe` / `initHub`). A custom host advertises itself with `registerDevframeInstance`, and a devtool enumerates running instances with `listLiveDevframeInstances`. Application code should prefer the adapters and `devframe/initiate`. ## `@devframes/hub` category order lives only on `/constants` diff --git a/docs/guide/security.md b/docs/guide/security.md index d845cca6..ed098d49 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -12,7 +12,7 @@ An RPC handler runs with the full privileges of the process hosting it — files Two postures cover that boundary: -- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. `devframe/recipes/interactive-auth`'s `createInteractiveAuth` packages the whole protocol — handshake handlers, the resolver gate, connect-time trust, and the code/link banner — into a single `DevframeAuthHandler` you pass straight to `startHttpAndWs({ auth })`. +- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. `devframe/recipes/interactive-auth`'s `createInteractiveAuth` packages the whole protocol — handshake handlers, the resolver gate, connect-time trust, and the code/link banner — into a single `DevframeAuthHandler` the adapters wire for you (and that you can pass straight to `initDevframe` / `initHub` via `auth`). - **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction. > [!WARNING] @@ -22,7 +22,7 @@ Two postures cover that boundary: Exactly one rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`). There is no separate allowlist to keep in sync — the two handshake methods below carry the prefix precisely because they're the only ones an unauthenticated caller needs. -`startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036). +The RPC server binding enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036). On the client, `connectDevframe()` kicks off the handshake below without waiting for it, so a naive client could otherwise race it — sending a trusted call over the freshly-opened socket before the server has had a chance to answer `anonymous:devframe:auth`, hitting this exact gate. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued while that first handshake is still in flight and release it once the handshake settles, so application code never has to special-case this window itself. @@ -43,15 +43,13 @@ The bearer token is a secret. It travels to the server on the WebSocket URL (`?d ### The ready-made layer ```ts -import { startHttpAndWs } from 'devframe/node' import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +// The adapters gate with this layer by default. Construct it yourself only to +// tune it (e.g. CI tokens) and hand it to `initDevframe` / `initHub` as `auth`. const auth = createInteractiveAuth(ctx, { clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined, }) - -const server = await startHttpAndWs({ context: ctx, port: 9999, auth }) -auth.printBanner() ``` `createInteractiveAuth` closes over the auth storage internally — nothing here reaches into `devframe/node/hub-internals`. Pass `clientAuthTokens` for CI/shared machines that should skip the interactive prompt entirely, or a custom `banner`/`serverUrl` to change how the code is presented. diff --git a/docs/helpers/interactive-auth.md b/docs/helpers/interactive-auth.md index 3f546f11..4ac19007 100644 --- a/docs/helpers/interactive-auth.md +++ b/docs/helpers/interactive-auth.md @@ -6,15 +6,19 @@ outline: deep A ready-made OTP auth layer over devframe's node-side primitives — the handshake RPC functions, the resolver gate, the connect-time trust hook, and the code/link banner — so a host doesn't re-implement the protocol on top of `exchangeTempAuthCode` / `verifyAuthToken` / `revokeAuthToken` itself. +The adapters gate with this layer by default, so the common path never constructs it by hand — `createDevServer(def)` (and `initDevframe` / `initHub` with `auth: true`) wire it and print its banner once the origin resolves. Reach for `createInteractiveAuth` directly only when you bind your own transport: + ```ts -import { startHttpAndWs } from 'devframe/node' +import { createContextRpcServer } from 'devframe/internal' import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' const auth = createInteractiveAuth(ctx, { clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined, }) -const server = await startHttpAndWs({ context: ctx, port: 9999, auth }) +const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ context: ctx, auth }) +attachWsRpcTransport(rpcGroup, { server, onConnected, onDisconnected }) auth.printBanner() ``` @@ -32,14 +36,14 @@ Returns a `DevframeAuthHandler`: | Field | Purpose | |-------|---------| -| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (the handshake) and `devframe:auth:revoke` (self-revoke) — register these on the RPC host if not passing the whole layer to `startHttpAndWs`. | +| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (the handshake) and `devframe:auth:revoke` (self-revoke) — register these on the RPC host if not passing the whole layer as `auth`. | | `authorize(methodName, session)` | The resolver gate: allows any `anonymous:`-prefixed method, otherwise requires `session.meta.isTrusted`. | | `onConnect(peer, session)` | Connect-time trust: reads a bearer off the peer's WS upgrade URL (`?devframe_auth_token=`) and trusts the session immediately when it's valid, before the client's own handshake call arrives. | | `printBanner()` | Prints the current code + magic-link URL. Safe to call repeatedly — it only prints once per code. | ## Using the pieces directly -Not using `startHttpAndWs`? Wire the same four pieces against your own transport: +Wiring a transport without `createContextRpcServer`? Wire the same four pieces against it directly: ```ts const auth = createInteractiveAuth(ctx) diff --git a/docs/helpers/next.md b/docs/helpers/next.md index 0f78d68f..1df9a67b 100644 --- a/docs/helpers/next.md +++ b/docs/helpers/next.md @@ -57,42 +57,33 @@ The base defaults to `def.basePath ?? '/__/'`. `close()` shuts the side-car ## Hosting a hub -For many devframes at once, use `createDevframeNextHost()` with [`@devframes/hub`](/guide/hub). Its `host` accumulates every `mountStatic` / `mountConnectionMeta` call into one `fetch` handler: +For many devframes at once, use [`@devframes/hub`](/guide/hub)'s `initHub` — one call assembles every frame under `/` behind a single web-standard `handler` you mount on a catch-all route: ```ts [devframe/host.ts] -import { createHubContext, mountDevframe } from '@devframes/hub/node' -import { createDevframeNextHost } from '@devframes/next' -import { startHttpAndWs } from 'devframe/internal' - -const nextHost = createDevframeNextHost({ - resolveOrigin: () => 'http://localhost:3000', - getStorageDir: scope => resolveStorageDir(scope), +import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' + +// Next route handlers can't accept WS upgrades, so the socket asks for a +// side-car of its own; the browser discovers it via `__connection.json`. +const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + devframes: [myDevframe], + ws: { sidecar: true }, + auth: false, }) -const context = await createHubContext({ host: nextHost.host, mode: 'dev' }) -await mountDevframe(context, myDevframe) -nextHost.host.mountConnectionMeta('/__hub') // the hub's own connection base - -const started = await startHttpAndWs({ context, port, auth: false }) -nextHost.setConnectionMeta({ backend: 'websocket', websocket: started.port }) - -export const hub = { fetch: nextHost.fetch } +export const { handler } = hub // mount on a `[[...path]]` route handler ``` -```ts [app/__[id]/[[...path]]/route.ts] +```ts [app/__devframes/[[...path]]/route.ts] export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export async function GET(request: Request): Promise { - return hub.fetch(request) // serves every mounted SPA + connection meta + return handler(request) // serves every mounted SPA + connection meta } ``` -`createDevframeNextHost` returns `{ host, fetch, setConnectionMeta }`: - -- **`host`** — the [`DevframeHost`](/guide/hub) to pass to `createHubContext` / `createHostContext`. -- **`fetch(request)`** — the handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a `/__connection.json` discovery fetch; a miss returns a bare `404`. -- **`setConnectionMeta(meta)`** — publish the live meta once the RPC/WS port is known. Until then, meta requests answer `503` so a racing client retries rather than caching a wrong endpoint. +`initHub` returns one `handler` that serves every mounted SPA, the discovery endpoints, and the hub-level transport. Connection meta is matched before the static handlers, so an SPA fallback never swallows a `__connection.json` discovery fetch; a miss returns a bare `404`. Memoize the instance on `globalThis` so Next's per-request route re-evaluation reuses one hub — see `examples/hub-next` for a full working host. ## React client @@ -131,5 +122,5 @@ Route handlers that call `fetch` pin `export const runtime = 'nodejs'`: the stat ## See also - [Vite Bridge](./vite-bridge) — the equivalent for Vite-based hosts -- [Hub](/guide/hub) — `createHubContext`, `mountDevframe`, and `DevframeHost` +- [Hub](/guide/hub) — `initHub`, `ctx.install`, and `DevframeHost` - [hub-next](/examples/hub-next) — a full working host diff --git a/docs/helpers/vite-bridge.md b/docs/helpers/vite-bridge.md index 71ce2fc1..87b0aae5 100644 --- a/docs/helpers/vite-bridge.md +++ b/docs/helpers/vite-bridge.md @@ -23,7 +23,7 @@ export default defineConfig({ - **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__/` by default). No RPC server. Useful when you only need the SPA bundle served from a known path. - **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port, so the descriptor carries that port alongside the `/__ws` route. -To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass an existing HTTP server and a route to [`startHttpAndWs`](/adapters/dev) via its `server` and `path` options. Devframe routes only that upgrade path and leaves the rest (Vite's HMR socket included) untouched. +To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass Vite's HTTP server to [`initDevframe`](/adapters/initiate) / `initHub` via the `server` option. Devframe binds only its own `__ws` upgrade route and leaves the rest (Vite's HMR socket included) untouched. ## Options diff --git a/docs/plugins/a11y.md b/docs/plugins/a11y.md index 14f0316d..4b82e26a 100644 --- a/docs/plugins/a11y.md +++ b/docs/plugins/a11y.md @@ -37,7 +37,7 @@ The agent is the a11y dock's [client script](/guide/client-context): attach the ```ts import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' -await mountDevframe(ctx, a11yDevframe, { +await ctx.install(a11yDevframe, { dock: { clientScript: { importFrom: `/@fs/${a11yAgentBundlePath}` } }, }) ``` diff --git a/examples/a11y-messages-playground/package.json b/examples/a11y-messages-playground/package.json index 87dbbd24..34d318f2 100644 --- a/examples/a11y-messages-playground/package.json +++ b/examples/a11y-messages-playground/package.json @@ -19,7 +19,6 @@ }, "devDependencies": { "@iconify-json/ph": "catalog:frontend", - "get-port-please": "catalog:deps", "pathe": "catalog:deps", "unocss": "catalog:frontend", "vite": "catalog:build" diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts index 294b612c..d6caea3c 100644 --- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts +++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts @@ -1,21 +1,16 @@ -import type { DevframeHubContext } from '@devframes/hub/node' +import type { HubInstance } from '@devframes/hub/initiate' import type { ClientScriptEntry } from '@devframes/hub/types' -import type { DevframeDefinition, DevframeHost } from 'devframe' -import type { DevframeInstanceRegistration } from 'devframe/internal' +import type { DevframeDefinition } from 'devframe' import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' +import { Server as NodeHttpServer } from 'node:http' import { homedir } from 'node:os' -import process from 'node:process' -import { createHubContext, mountDevframe } from '@devframes/hub/node' -import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { registerDevframeInstance, startHttpAndWs } from 'devframe/internal' -import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' -import { getPort } from 'get-port-please' +import { initHub } from '@devframes/hub/initiate' import { join } from 'pathe' export interface A11yMessagesPlaygroundOptions { - /** Mount path for the hub's connection-meta endpoint. Default: `/__hub/`. */ + /** Mount base the hub answers under. Default: `/__hub/`. */ base?: string - /** Preferred port for the side-car RPC/WS server. Default: a free port near 9878. */ + /** Pin the side-car RPC/WS port instead of sharing Vite's server / a random one. */ port?: number /** Devframes to mount as docks (here: a11y + messages). */ devframes?: DevframeDefinition[] @@ -29,17 +24,17 @@ export interface A11yMessagesPlaygroundOptions { /** * A tiny Vite plugin that runs `@devframes/hub` inside the Vite dev server — - * the same shape as `examples/vite-devframe-hub`, trimmed to the two - * plugins this playground pairs (a11y + messages). It creates a hub context, - * implements the framework-neutral `DevframeHost` surface, mounts each devframe - * as a dock (attaching the a11y agent as its client script), and exposes the - * side-car WS endpoint at `__connection.json`. + * the same shape as `examples/hub-vite`, trimmed to the two plugins this + * playground pairs (a11y + messages). One `initHub()` call assembles the whole + * hub: it mounts each devframe as a dock (attaching the a11y agent as its + * client script), shares the WebSocket with Vite's own server, serves the + * discovery endpoints, and registers the playground in the global instance + * registry. */ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined - let started: { close: () => Promise } | undefined - let registration: DevframeInstanceRegistration | undefined + let hub: HubInstance | undefined return { name: 'a11y-messages-playground', @@ -50,36 +45,24 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = }, async configureServer(server: ViteDevServer) { - // Vite re-invokes `configureServer` on restart — tear the old server down - // so we don't leak the WS port, and drop the previous registry record so - // a restart doesn't leave a ghost instance behind. - await started?.close().catch(() => {}) - started = undefined - registration?.unregister() - registration = undefined + // Vite re-invokes `configureServer` on restart — tear the old hub down so + // we don't leak the WS port or leave a ghost registry record behind. + await hub?.close().catch(() => {}) const cwd = viteConfig!.root - const port = options.port ?? await getPort({ port: 9878, portRange: [9878, 9978] }) - - const serveConnectionMeta = (metaBase: string): void => { - const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}` - server.middlewares.use(metaPath, (_req, res) => { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port })) - }) - } - - const host: DevframeHost = { - mountStatic(base, distDir) { - server.middlewares.use(base, serveStaticNodeMiddleware(distDir)) - }, - mountConnectionMeta(base) { - serveConnectionMeta(base) - }, - resolveOrigin() { - const resolved = server.resolvedUrls?.local?.[0] - return resolved ? new URL(resolved).origin : 'http://localhost:5173' - }, + // Share Vite's own HTTP server for the WS upgrade when it's a plain + // `node:http` server; otherwise (a pinned port, or an https/http2 dev + // server) fall back to a side-car. Either way the browser finds the + // socket through `__connection.json`. + const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined + const ws = options.port != null ? { port: options.port } : httpServer ? undefined : { sidecar: true as const } + + hub = initHub({ + base, + cwd, + auth: false, + ...(options.port == null && httpServer ? { server: httpServer } : {}), + ...(ws ? { ws } : {}), getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') @@ -87,67 +70,29 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = return join(cwd, 'node_modules/.a11y-messages-playground') return join(homedir(), '.a11y-messages-playground') }, - } - - const context: DevframeHubContext = await createHubContext({ - cwd, - workspaceRoot: cwd, - mode: 'dev', - host, - }) - - // Mount each devframe as a dock, attaching its client script when one is - // configured (the a11y agent). `mountDevframe` runs the def's `setup(ctx)`, - // so `setupA11y` / `setupMessages` register their RPCs automatically. - for (const def of options.devframes ?? []) { - const clientScript = options.clientScripts?.[def.id] - await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined) - } - - started = await startHttpAndWs({ context, port, auth: false }) - - // Tell the hub UI (served at `base`) where to find the WS endpoint. - serveConnectionMeta(base) - - // Register this playground in the global instance registry - // (`~/.devframe/instances/`) so discovery tooling — `devframe connect` - // and the inspector's Instances tab — lists it like any standalone - // devframe. See `examples/vite-devframe-hub` for the same pattern. - const register = (): void => { - const origin = host.resolveOrigin() - const url = new URL(origin) - registration = registerDevframeInstance({ - pid: process.pid, - port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80), - origin, - basePath: base, + devframes: (options.devframes ?? []).map((def) => { + const clientScript = options.clientScripts?.[def.id] + return clientScript ? { devframe: def, dock: { clientScript } } : def + }), + // List the playground alongside standalone devframes in discovery + // tooling (`devframe connect`, the inspector's Instances tab). + register: { id: 'example:a11y-messages-playground', name: 'A11y + Messages Playground', - rootDir: cwd, - mcp: null, - startedAt: Date.now(), - }) - } - if (server.httpServer?.listening) - register() - else - server.httpServer?.once('listening', register) + }, + }) - const closeStarted = started.close - started.close = async () => { - registration?.unregister() - registration = undefined - await closeStarted() - } + server.middlewares.use(hub.nodeMiddleware) server.httpServer?.once('close', () => { - void started?.close().catch(() => {}) + void hub?.close().catch(() => {}) + hub = undefined }) }, async closeBundle() { - await started?.close().catch(() => {}) - started = undefined + await hub?.close().catch(() => {}) + hub = undefined }, } } diff --git a/examples/files-inspector/tests/_utils.ts b/examples/files-inspector/tests/_utils.ts index bf395ed4..e5a74c52 100644 --- a/examples/files-inspector/tests/_utils.ts +++ b/examples/files-inspector/tests/_utils.ts @@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url' import { DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import devframe from '../src/devframe' const HERE = fileURLToPath(new URL('.', import.meta.url)) @@ -80,7 +81,7 @@ export async function startInspectorServer( app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, resolve(distDir)) - const server = await startHttpAndWs({ + const server = await serveTestContext({ context: ctx, host, port, diff --git a/examples/hub-next/src/client/devframe/demo-devframe-b.ts b/examples/hub-next/src/client/devframe/demo-devframe-b.ts index 984d74ff..cbb3235c 100644 --- a/examples/hub-next/src/client/devframe/demo-devframe-b.ts +++ b/examples/hub-next/src/client/devframe/demo-devframe-b.ts @@ -32,7 +32,7 @@ export default defineDevframe({ await ctx.messages.add({ level: 'info', message: 'Second Next demo devframe loaded', - description: 'A second mountDevframe() call — proves the switcher has more than one option.', + description: 'A second devframe in the hub `devframes` list — proves the switcher has more than one option.', }) }, }) diff --git a/examples/hub-next/src/client/devframe/demo-devframe.ts b/examples/hub-next/src/client/devframe/demo-devframe.ts index 154d79fa..0f5d1dc6 100644 --- a/examples/hub-next/src/client/devframe/demo-devframe.ts +++ b/examples/hub-next/src/client/devframe/demo-devframe.ts @@ -14,7 +14,7 @@ export default defineDevframe({ version: pkg.version, packageName: pkg.name, homepage: pkg.homepage, - description: 'A tiny demo devframe mounted into the Next.js hub via mountDevframe().', + description: 'A tiny demo devframe mounted into the Next.js hub via its `devframes` list.', icon: 'ph:rocket-duotone', basePath: '/__next-demo-tool/', cli: { @@ -34,7 +34,7 @@ export default defineDevframe({ await ctx.messages.add({ level: 'info', message: 'Next demo devframe loaded', - description: 'Registered via mountDevframe() from the Next host.', + description: 'Registered via the hub `devframes` list from the Next host.', }) }, }) diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts index f593e5af..9ef79011 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -1,14 +1,12 @@ import type { HubDevframeEntry, HubInstance } from '@devframes/hub/initiate' import type { DevframeHubContext } from '@devframes/hub/node' import type { DevframeDefinition } from 'devframe' -import type { DevframeInstanceRegistration } from 'devframe/internal' import { homedir } from 'node:os' import process from 'node:process' import { fileURLToPath } from 'node:url' import { defineHubRpcFunction } from '@devframes/hub' import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' -import { registerDevframeInstance } from 'devframe/internal' import { createDashboardView } from 'json-render/dashboard' import { dirname, join } from 'pathe' import demoDevframe from './demo-devframe' @@ -182,8 +180,6 @@ export async function nextDevframeHub( }, ] - let registration: DevframeInstanceRegistration | undefined - const hub = initHub({ base: DEVFRAMES_HUB_BASE, cwd, @@ -212,6 +208,15 @@ export async function nextDevframeHub( nextHubTerminalsList, ], devframes, + // Record this hub in the global registry so `devframe connect` discovers + // it — running inside the Next dev server — like any standalone devframe. + // The instance owns the record (written once its pinned origin resolves, + // removed on close); the aggregate MCP path is derived from `mcp: true`. + register: { + id: 'example:next-devframe-hub', + name: 'Next Devframe Hub', + rootDir: cwd, + }, async configure(ctx) { ctx.commands.register({ id: 'example:next-devframe-hub:ping', @@ -257,34 +262,10 @@ export async function nextDevframeHub( message: 'Next Devframe Hub started', description: `${devframes.length} devframe(s) mounted under ${DEVFRAMES_HUB_BASE}.`, }) - - // Record the instance in the global registry so `devframe connect` - // discovers this hub — running inside the Next dev server — like any - // standalone devframe. In-process hosts register explicitly; the origin - // is the Next app's own. - registration = registerDevframeInstance({ - pid: process.pid, - port: nextPort, - origin, - basePath: DEVFRAMES_HUB_BASE, - id: 'example:next-devframe-hub', - name: 'Next Devframe Hub', - rootDir: cwd, - mcp: { path: `${DEVFRAMES_HUB_BASE}__mcp` }, - startedAt: Date.now(), - }) }, }) - const closeHub = hub.close - return { - ...hub, - close: async () => { - registration?.unregister() - registration = undefined - await closeHub() - }, - } + return hub } /** diff --git a/examples/hub-vite/src/devframe-b.ts b/examples/hub-vite/src/devframe-b.ts index e67fd405..59b23ce8 100644 --- a/examples/hub-vite/src/devframe-b.ts +++ b/examples/hub-vite/src/devframe-b.ts @@ -31,7 +31,7 @@ export default defineDevframe({ await ctx.messages.add({ level: 'info', message: 'Second demo devframe loaded', - description: 'A second mountDevframe() call — proves the switcher has more than one option.', + description: 'A second devframe in the hub `devframes` list — proves the switcher has more than one option.', }) }, }) diff --git a/examples/hub-vite/src/devframe.ts b/examples/hub-vite/src/devframe.ts index 51215070..26fe2fb6 100644 --- a/examples/hub-vite/src/devframe.ts +++ b/examples/hub-vite/src/devframe.ts @@ -5,7 +5,7 @@ import pkg from '../package.json' with { type: 'json' } /** * A tiny demo devframe — proves a portable devframe can plug into the - * hub via {@link mountDevframe} and register its own docks / commands / + * hub via `ctx.install` 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 @@ -20,7 +20,7 @@ export default defineDevframe({ version: pkg.version, packageName: pkg.name, homepage: pkg.homepage, - description: 'A tiny demo devframe that plugs into the hub via mountDevframe().', + description: 'A tiny demo devframe that plugs into the hub via its `devframes` list.', icon: 'ph:rocket-duotone', basePath: '/__demo-tool/', cli: { @@ -40,7 +40,7 @@ export default defineDevframe({ await ctx.messages.add({ level: 'info', message: 'Demo devframe loaded', - description: 'Registered via mountDevframe(). Proves the devframe ↔ hub plug-in path works.', + description: 'Registered via the hub `devframes` list. Proves the devframe ↔ hub plug-in path works.', }) }, }) diff --git a/examples/hub-vite/src/vite-devframe-hub.ts b/examples/hub-vite/src/vite-devframe-hub.ts index 42bbeac1..f0f28027 100644 --- a/examples/hub-vite/src/vite-devframe-hub.ts +++ b/examples/hub-vite/src/vite-devframe-hub.ts @@ -2,14 +2,11 @@ import type { HubDevframeEntry, HubInstance } from '@devframes/hub/initiate' import type { DevframeHubContext } from '@devframes/hub/node' import type { ClientScriptEntry } from '@devframes/hub/types' import type { DevframeDefinition } from 'devframe' -import type { DevframeInstanceRegistration } from 'devframe/internal' import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' import { Server as NodeHttpServer } from 'node:http' import { homedir } from 'node:os' -import process from 'node:process' import { defineHubRpcFunction } from '@devframes/hub' import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' -import { registerDevframeInstance } from 'devframe/internal' import { join } from 'pathe' export interface ViteDevframeHubOptions { @@ -87,13 +84,10 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const base = normalizeBase(options.base ?? DEVFRAMES_HUB_BASE) let viteConfig: ResolvedConfig | undefined let instance: HubInstance | undefined - let registration: DevframeInstanceRegistration | undefined - // Every teardown path funnels here: drop the instance-registry record, - // then close the hub (WS binding / side-car, mounted frames' resources). + // Every teardown path funnels here: close the hub (WS binding / side-car, + // its instance-registry record, and mounted frames' resources). const teardown = async (): Promise => { - registration?.unregister() - registration = undefined const previous = instance instance = undefined await previous?.close().catch(() => {}) @@ -109,8 +103,8 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { async configureServer(server: ViteDevServer) { // Vite re-invokes `configureServer` on each restart. Tear down the - // previous instance so we don't leak the WS binding, and drop the - // previous registry record so a restart doesn't leave a ghost instance. + // previous instance so we don't leak the WS binding or leave a ghost + // registry record behind. await teardown() const cwd = viteConfig!.root @@ -132,10 +126,12 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const hub = initHub({ base, cwd, - // Resolved lazily — Vite knows its local URL only once listening. + // Resolved lazily — Vite knows its local URL only once listening; an + // empty string until then defers both the auth banner and the registry + // record to the first request, whose origin is the real dialable one. origin: () => { const resolved = server.resolvedUrls?.local?.[0] - return resolved ? new URL(resolved).origin : 'http://localhost:5173' + return resolved ? new URL(resolved).origin : '' }, // Single-user localhost demo: the hub is reachable only on loopback, // so it opts out of the gate for a no-friction dev experience. A hub @@ -159,6 +155,16 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { return join(cwd, 'node_modules/.vite-devframe-hub') return join(homedir(), '.vite-devframe-hub') }, + // List this hub in the global instance registry (`~/.devframe/instances/`) + // so discovery tooling — `devframe connect`, the inspector's Instances + // tab — sees it like any standalone devframe. The instance owns the + // record: written once the first request resolves the dialable origin, + // removed on close. `rootDir` is the Vite project root. + register: { + id: 'example:vite-devframe-hub', + name: 'Vite Devframe Hub', + rootDir: cwd, + }, rpcDeclarations: [ // The minimal hub ships its own `messages:list` and `terminals:list` // RPCs so the UI has something to read. A full hub kit would @@ -195,37 +201,6 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // `base` and `next()`s the rest back to Vite. server.middlewares.use(hub.nodeMiddleware) - // Record this hub in the global instance registry (`~/.devframe/instances/`) - // so discovery tooling — `devframe connect` and the inspector's Instances - // tab — lists it like any standalone devframe. `createDevServer` registers - // automatically; an in-process host like this one registers explicitly, - // reusing the Vite dev server's own origin (where `__connection.json` - // is served). Registration waits for the server to be listening so the - // origin/port are known; `teardown` unregisters on every close path - // (restart, httpServer close, `closeBundle`). - const register = (): void => { - if (instance !== hub) - return - const resolved = server.resolvedUrls?.local?.[0] - const origin = resolved ? new URL(resolved).origin : 'http://localhost:5173' - const url = new URL(origin) - registration = registerDevframeInstance({ - pid: process.pid, - port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80), - origin, - basePath: base, - id: 'example:vite-devframe-hub', - name: 'Vite Devframe Hub', - rootDir: cwd, - mcp: null, - startedAt: Date.now(), - }) - } - if (server.httpServer?.listening) - register() - else - server.httpServer?.once('listening', register) - server.httpServer?.once('close', () => { if (instance !== hub) return diff --git a/examples/next-runtime-snapshot/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts index d1b41da3..27625f58 100644 --- a/examples/next-runtime-snapshot/tests/_utils.ts +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -1,12 +1,13 @@ import type { StartedServer } from 'devframe/internal' import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import devframe from '../src/devframe' export interface SnapshotServer extends StartedServer { @@ -45,7 +46,7 @@ export async function startSnapshotServer(): Promise { // RPC-only tests don't fetch the SPA, so they're unaffected. mountStaticHandler(app, basePath, resolve(distDir)) - const server = await startHttpAndWs({ + const server = await serveTestContext({ context: ctx, host, port, diff --git a/examples/streaming-chat/tests/_utils.ts b/examples/streaming-chat/tests/_utils.ts index 2911285a..11e2f578 100644 --- a/examples/streaming-chat/tests/_utils.ts +++ b/examples/streaming-chat/tests/_utils.ts @@ -6,12 +6,13 @@ import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import devframe from '../src/devframe' /** @@ -50,7 +51,7 @@ export async function startStreamingChatServer(): Promise { } }) - it('forwards onPeerConnect/onPeerDisconnect to the underlying startHttpAndWs', async () => { + it('forwards onPeerConnect/onPeerDisconnect to the underlying WS transport binding', async () => { const devframe = defineDevframe({ id: 'devframe-peer-hooks', name: 'Peer Hooks', diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index a9df322a..cbdb730c 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -59,7 +59,7 @@ export async function resolveDevServerPort( * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into * concrete options, or `undefined` when the MCP route is disabled. */ -export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { +function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { if (!mcp) return undefined return mcp === true ? {} : mcp diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 36730ab2..dd4513ba 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,18 +1,15 @@ import type { Peer } from 'crossws' import type { DevframeAuthHandler } from '../node/auth/handler' -import type { StartedServer } from '../node/server' +import type { StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' import { createServer } from 'node:http' -import process from 'node:process' import { open } from 'devframe/utils/open' import { H3, toNodeHandler } from 'h3' -import { joinURL, withBase, withoutLeadingSlash } from 'ufo' -import { DEVFRAME_MCP_ROUTE } from '../constants' +import { withBase } from 'ufo' import { diagnostics } from '../node/diagnostics' -import { registerDevframeInstance } from '../node/instance-registry' import { normalizeHttpServerUrl } from '../node/utils' -import { normalizeBasePath, resolveBasePath, resolveDevServerPort, resolveMcpConfig } from './_shared' +import { normalizeBasePath, resolveBasePath, resolveDevServerPort } from './_shared' import { getInstanceInternals, initDevframe } from './initiate' export { resolveDevServerPort, resolveMcpConnectionMeta } from './_shared' @@ -86,13 +83,13 @@ export interface CreateDevServerOptions { mcp?: boolean | McpRouteOptions /** * Called once per new WS connection, right after its session is created. - * Forwarded verbatim to the underlying `startHttpAndWs`. + * Forwarded verbatim to the underlying WS transport binding. */ onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void /** * Called once per closed WS connection, right after its session's * disconnect bookkeeping runs. Forwarded verbatim to the underlying - * `startHttpAndWs`. + * the underlying WS transport binding. */ onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void /** @@ -178,6 +175,10 @@ export async function createDevServer( flags, onPeerConnect: options.onPeerConnect, onPeerDisconnect: options.onPeerDisconnect, + // Publish in the global registry so discovery tooling (`devframe connect`) + // finds the server without port guessing. The instance owns the record's + // lifecycle — written once its (pinned) origin resolves, removed on close. + register: true, // This server is devframe's own — nothing else handles its upgrades, so // off-route upgrade attempts are rejected promptly. destroyUnmatchedUpgrades: true, @@ -193,29 +194,12 @@ export async function createDevServer( const internals = getInstanceInternals(devframe) // Every dev-server configuration binds a local transport (`server` is - // always passed), so the startHttpAndWs handle is always present. + // always passed), so the bound-transport handle is always present. const transport = internals.started! await options.onReady?.({ origin, port, app }) await maybeOpenBrowser(def, flags, `${origin}${basePath}`, options.openBrowser, internals.authHandler) - const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) - - // Record the instance in the global registry so discovery tooling - // (`devframe connect`) finds it without port guessing. Registration never - // throws; a crash-orphaned record is pruned by readers on a failed probe. - const registration = registerDevframeInstance({ - pid: process.pid, - port, - origin, - basePath, - id: def.id, - name: def.name, - rootDir: process.cwd(), - mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)) } : null, - startedAt: Date.now(), - }) - return { origin, port, @@ -224,10 +208,9 @@ export async function createDevServer( rpcGroup: transport.rpcGroup, connectionMeta: transport.connectionMeta, async close() { - registration.unregister() - // Instance teardown detaches the WS transport (and closes any - // dedicated-port socket server) and disposes MCP sessions; the HTTP - // server is this function's own to close. + // Instance teardown removes the registry record, detaches the WS + // transport (and closes any dedicated-port socket server), and disposes + // MCP sessions; the HTTP server is this function's own to close. await devframe.close() await new Promise(resolveClose => server.close(() => resolveClose())) }, diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 3d8a7029..cf104172 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -5,8 +5,8 @@ import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' import type { DevframeAuthHandler } from '../node/auth/handler' -import type { InstanceShellInternals } from '../node/instance-shell' -import type { StartedServer } from '../node/server' +import type { DevframeInstanceRecord } from '../node/instance-registry' +import type { InstanceShellInternals, StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import process from 'node:process' import { mountStaticHandler } from 'devframe/utils/serve-static' @@ -17,7 +17,7 @@ import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' import { createHostContext } from '../node/context' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' -import { createInstanceShell } from '../node/instance-shell' +import { createInstanceShell, resolveInstanceRegister } from '../node/instance-shell' import { normalizeBasePath } from './_shared' import { resolveDevServerPort, resolveMcpConnectionMeta } from './dev' @@ -89,6 +89,15 @@ export interface InitDevframeOptions { * URLs. */ origin?: string | (() => string) + /** + * Publish this instance in the global registry (`~/.devframe/instances/`) + * so discovery tooling (`devframe connect`, the inspect plugin's Instances + * tab) finds it without port guessing. Registration is a dynamic import + * that fires once the public origin resolves and is torn down on + * {@link DevframeInstance.close}. Defaults to off; pass `true` to enable, or + * an object to override individual record fields (`id`, `name`, `mcp`, …). + */ + register?: boolean | Partial /** Parsed flag bag forwarded to `def.setup(ctx, { flags })`. */ flags?: Record /** @@ -121,13 +130,12 @@ export interface InitDevframeOptions { destroyUnmatchedUpgrades?: boolean /** * Called once per new WS connection, right after its session is created. - * Forwarded verbatim to the underlying transport (see - * `StartHttpAndWsOptions.onPeerConnect`). + * Forwarded verbatim to the underlying transport. */ onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void /** * Called once per closed WS connection, right after the transport's own - * disconnect bookkeeping runs (see `StartHttpAndWsOptions.onPeerDisconnect`). + * disconnect bookkeeping runs. */ onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void } @@ -195,7 +203,7 @@ export interface DevframeInstance { * @internal */ export interface DevframeInstanceInternals { - /** The `startHttpAndWs` handle backing the side-car / shared-server WS tiers. */ + /** The bound HTTP+WS server handle backing the side-car / shared-server WS tiers. */ readonly started?: StartedServer /** The resolved auth handler when the gate is active. */ readonly authHandler?: DevframeAuthHandler @@ -248,6 +256,7 @@ export function initDevframe( destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, onPeerConnect: options.onPeerConnect, onPeerDisconnect: options.onPeerDisconnect, + register: resolveInstanceRegister(options.register, { id: def.id, name: def.name }), resolveSidecarPort: sidecarHost => resolveDevServerPort(def, { host: sidecarHost }), onMetaUnavailable: () => { throw diagnostics.DF0054({ id: def.id }) diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index 081f175b..15fb7385 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -1,4 +1,4 @@ -import type { StartedServer } from '../../../node/server' +import type { StartedServer } from '../../../node/instance-shell' import type { DevframeDefinition } from '../../../types/devframe' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { afterEach, describe, expect, it } from 'vitest' diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index c970dc51..27a94b16 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -75,7 +75,7 @@ export const ANONYMOUS_RPC_PREFIX = 'anonymous:' /** * Whether `name` is callable before a connection is trusted, i.e. it starts * with {@link ANONYMOUS_RPC_PREFIX}. Used by the resolver gate in - * `startHttpAndWs` (via an `authorize` function) and by host adapters that + * the RPC server binding (via an `authorize` function) and by host adapters that * implement their own transport. */ export function isAnonymousRpcMethod(name: string): boolean { diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index ce81f320..6f7458bf 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -6,7 +6,7 @@ // // - `createContextRpcServer` — the transport-agnostic RPC core; a host that // binds its own transport (e.g. the hub's `initHub`) reuses the exact -// session/auth wiring `startHttpAndWs` uses. +// session/auth wiring the instance shell's own binding uses. // - `DevframeAgentHost` — the agent host implementation the hub composes into // its own commands host. // - `coerceAgentPositionalArgs` — positional-arg coercion the hub applies when @@ -16,11 +16,11 @@ // Instances tab, the connector) enumerates what's running. // - `createH3DevframeHost` — the node/standalone `DevframeHost` implementation // (filesystem storage paths + origin resolution) passed to `createHostContext`. -// - `startHttpAndWs` — the low-level "listen on a port + attach the WS -// transport" primitive the adapters and `initHub` are built on. // - `createInstanceShell` — the shared machinery behind `initDevframe` and // `initHub`: mount base, h3 app, lazy origin + auth banner, WebSocket -// binding resolution, the fetch/connect handler pair, and teardown. +// binding resolution ("listen on a port / share one + attach the WS +// transport"), the fetch/connect handler pair, and teardown. `StartedServer` +// is the live handle its bound tiers produce and `createDevServer` re-exposes. // - `normalizeHttpServerUrl` — a small host-side URL helper. export { coerceAgentPositionalArgs } from '../node/agent-args' export type { AgentArgsFallback } from '../node/agent-args' @@ -28,16 +28,17 @@ export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' export { listLiveDevframeInstances, registerDevframeInstance } from '../node/instance-registry' export type { DevframeInstanceRecord, DevframeInstanceRegistration } from '../node/instance-registry' -export { createInstanceShell, samePath } from '../node/instance-shell' +export { createInstanceShell, resolveInstanceRegister, samePath } from '../node/instance-shell' export type { CreateInstanceShellOptions, + InstanceRegisterConfig, InstanceShell, InstanceShellApi, InstanceShellInit, InstanceShellInternals, InstanceWsTier, + StartedServer, } from '../node/instance-shell' export { createContextRpcServer } from '../node/rpc-core' export type { ContextRpcServer, CreateContextRpcServerOptions } from '../node/rpc-core' -export * from '../node/server' export { normalizeHttpServerUrl } from '../node/utils' diff --git a/packages/devframe/src/node/__tests__/server.test.ts b/packages/devframe/src/node/__tests__/server.test.ts deleted file mode 100644 index d62a109a..00000000 --- a/packages/devframe/src/node/__tests__/server.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { DevframeHost, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createRpcClient } from 'devframe/rpc/client' -import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' -import { getPort } from 'get-port-please' -import { describe, expect, it, vi } from 'vitest' -import { WebSocket } from 'ws' -import { createHostContext } from '../context' -import { startHttpAndWs } from '../server' - -function makeHost(storageDir: string): DevframeHost { - return { - mountStatic: () => {}, - resolveOrigin: () => 'http://localhost', - getStorageDir: () => storageDir, - } -} - -async function createTestContext(): Promise { - const storageDir = mkdtempSync(join(tmpdir(), 'devframe-server-')) - return createHostContext({ cwd: storageDir, mode: 'dev', host: makeHost(storageDir) }) -} - -function connectClient(host: string, port: number) { - return createRpcClient( - {} as DevframeRpcClientFunctions, - { channel: createWsRpcChannel({ url: `ws://${host}:${port}` }) }, - ) -} - -describe('startHttpAndWs rpcOptions passthrough', () => { - it('forwards a thrown handler error to rpcOptions.onFunctionError without swallowing the response', async () => { - const context = await createTestContext() - context.rpc.register({ - name: 'test:boom', - type: 'action', - handler: () => { - throw new Error('kaboom') - }, - }) - - const onFunctionError = vi.fn() - const host = '127.0.0.1' - const port = await getPort({ port: 0, host }) - const server = await startHttpAndWs({ - context, - host, - port, - auth: false, - rpcOptions: { onFunctionError }, - }) - - try { - const client = connectClient(host, port) - await expect(client.$call('test:boom' as any)).rejects.toThrow('kaboom') - - expect(onFunctionError).toHaveBeenCalledTimes(1) - const [error, name] = onFunctionError.mock.calls[0]! - expect(name).toBe('test:boom') - expect((error as Error).message).toBe('kaboom') - client.$close() - } - finally { - await server.close() - } - }) - - it('forwards a deserialize failure to rpcOptions.onGeneralError', async () => { - const context = await createTestContext() - // Returning `true` tells birpc the error was handled, suppressing its - // default rethrow — matches how a "log and swallow" host would use this. - const onGeneralError = vi.fn(() => true) - const host = '127.0.0.1' - const port = await getPort({ port: 0, host }) - const server = await startHttpAndWs({ - context, - host, - port, - auth: false, - rpcOptions: { onGeneralError }, - }) - - try { - const raw = new WebSocket(`ws://${host}:${port}`) - await new Promise((resolve, reject) => { - raw.once('open', () => resolve()) - raw.once('error', reject) - }) - raw.send('not valid json and not structured-clone either') - - await vi.waitFor(() => { - expect(onGeneralError).toHaveBeenCalledTimes(1) - }) - raw.close() - } - finally { - await server.close() - } - }) -}) - -describe('startHttpAndWs onPeerConnect / onPeerDisconnect', () => { - it('forwards both hooks, symmetrically, for the same peer', async () => { - const context = await createTestContext() - const onPeerConnect = vi.fn() - const onPeerDisconnect = vi.fn() - const host = '127.0.0.1' - const port = await getPort({ port: 0, host }) - const server = await startHttpAndWs({ - context, - host, - port, - auth: false, - onPeerConnect, - onPeerDisconnect, - }) - - try { - const raw = new WebSocket(`ws://${host}:${port}`) - await new Promise((resolve, reject) => { - raw.once('open', () => resolve()) - raw.once('error', reject) - }) - - await vi.waitFor(() => { - expect(onPeerConnect).toHaveBeenCalledTimes(1) - }) - const [, session] = onPeerConnect.mock.calls[0]! - expect(onPeerDisconnect).not.toHaveBeenCalled() - - raw.close() - - await vi.waitFor(() => { - expect(onPeerDisconnect).toHaveBeenCalledTimes(1) - }) - const [, meta] = onPeerDisconnect.mock.calls[0]! - // Same underlying session — proven by the stable meta id — closing out. - expect(meta.id).toBe(session.meta.id) - } - finally { - await server.close() - } - }) -}) - -describe('startHttpAndWs listen failures', () => { - it('rejects when the port is already taken instead of hanging', async () => { - const host = '127.0.0.1' - const first = await startHttpAndWs({ context: await createTestContext(), host, port: 0, auth: false }) - - try { - await expect( - startHttpAndWs({ context: await createTestContext(), host, port: first.port, auth: false }), - ).rejects.toThrow(expect.objectContaining({ code: 'DF0052' })) - } - finally { - await first.close() - } - }) -}) diff --git a/packages/devframe/src/node/auth/handler.ts b/packages/devframe/src/node/auth/handler.ts index 83057788..b2b65ae8 100644 --- a/packages/devframe/src/node/auth/handler.ts +++ b/packages/devframe/src/node/auth/handler.ts @@ -9,7 +9,7 @@ import type { DevframeNodeRpcSession } from 'devframe/types' * the handshake RPC functions, the resolver gate, the connect-time trust * hook, and the startup banner. * - * `startHttpAndWs` accepts one of these directly via its `auth` option — + * `initDevframe` / `initHub` accept one of these directly via their `auth` option — * see {@link https://devfra.me | devframe}'s server docs — or a host can * wire the four pieces itself against a lower-level transport. */ diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index 8eea3c4b..15dd6e25 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -22,7 +22,7 @@ export type { RpcFunctionsHost } from 'devframe/types' * Concrete implementation backing `ctx.rpc`. Internal: consumers should * depend on the structural {@link RpcFunctionsHost} type, never this class. * Its `@internal` members (`_rpcGroup`, `_asyncStorage`, - * `_emitSessionDisconnected`) are wired by `startHttpAndWs` and must not + * `_emitSessionDisconnected`) are wired by `createContextRpcServer` and must not * widen the public surface. * * @internal diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 064dd91f..c5fb151a 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -1,8 +1,8 @@ // Node-side public API for building a devframe context: `createHostContext` // (the context assembler) and `createStorage`. // -// The server-assembly primitives (`createH3DevframeHost`, `startHttpAndWs`) and -// everything lower-level live at `devframe/internal` (an explicitly-unstable +// The server-assembly primitives (`createH3DevframeHost`, `createInstanceShell`) +// and everything lower-level live at `devframe/internal` (an explicitly-unstable // cross-package surface): the transport-agnostic RPC core, the agent host, the // instance registry, and the `normalizeHttpServerUrl` helper. Application code // serves a devframe through the adapters (`createDevServer`, `createBuild`, diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 6f46dbc8..6b125eb4 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -1,21 +1,161 @@ +import type { BirpcGroup } from 'birpc' import type { Peer } from 'crossws' +import type { NodeAdapter } from 'crossws/adapters/node' import type { WsOriginRegistry, WsRpcTransport } from 'devframe/rpc/transports/ws-server' import type { H3 } from 'h3' import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' -import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types' +import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../types' import type { DevframeWsOptions } from '../types/devframe' import type { DevframeAuthHandler } from './auth' -import type { StartedServer } from './server' -import { H3 as H3App } from 'h3' +import type { RpcFunctionsHostImpl } from './host-functions' +import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry' +import { createServer } from 'node:http' +import process from 'node:process' +import { H3 as H3App, toNodeHandler } from 'h3' import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo' import { DEVFRAME_WS_ROUTE } from '../constants' import { createInteractiveAuth } from '../recipes/interactive-auth' import { diagnostics } from './diagnostics' import { getInternalContext } from './hub-internals/context' -import { startHttpAndWs } from './server' -import { formatHostForUrl } from './utils' +import { formatHostForUrl, normalizeHttpServerUrl } from './utils' + +/** + * The live handle for a bound HTTP + WebSocket RPC server — what the + * side-car / shared-server tiers produce and what {@link createDevServer} + * re-exposes through its own return contract. + */ +export interface StartedServer { + /** Listening origin, e.g. `http://localhost:9999`. */ + origin: string + port: number + app: H3 + /** The crossws node adapter driving the RPC socket (connected peers, pub/sub). */ + ws: NodeAdapter + rpcGroup: BirpcGroup + /** + * The {@link ConnectionMeta} descriptor for this server — the same shape a + * `__connection.json` route should serve so a devframe client's + * `resolveWsUrl` can dial back in. + */ + connectionMeta: () => ConnectionMeta + close: () => Promise +} + +/** How {@link bindHttpAndWs} binds the socket: own a fresh server, or share one. */ +interface BindHttpAndWsOptions { + context: DevframeNodeContext + host: string + /** Listening port for an owned server; ignored when `server` is supplied. */ + port: number + /** Share an existing `node:http` server instead of creating one. */ + server?: NodeHttpServer + /** Bind the WS upgrade to a single route instead of every upgrade on the port. */ + path?: string + auth?: boolean | DevframeAuthHandler + allowedOrigins?: readonly string[] | WsOriginRegistry | false + destroyUnmatched?: boolean + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void +} + +/** + * Compose an h3 + WebSocket RPC server for a devframe context — the low-level + * "listen on a port (or share one) + attach the WS transport" binding the + * side-car and shared-server tiers below are built on. Owns and listens on a + * fresh `node:http` server unless `server` is supplied, in which case it only + * attaches the upgrade listener and leaves that server's lifecycle to its + * owner. + */ +async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise { + const { context, port } = options + const bindHost = options.host + const app = new H3App() + const ownsHttpServer = !options.server + const httpServer = options.server ?? createServer(toNodeHandler(app)) + const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl + + const [{ createContextRpcServer }, { attachWsRpcTransport }] = await Promise.all([ + import('./rpc-core'), + import('devframe/rpc/transports/ws-server'), + ]) + + const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ + context, + auth: options.auth, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, + }) + + const { ws, close: closeWs } = attachWsRpcTransport(rpcGroup, { + server: httpServer, + path: options.path, + destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer, + allowedOrigins: options.allowedOrigins, + onConnected, + onDisconnected, + }) + + if (ownsHttpServer) { + try { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + // Without this listener a failed bind emits `error` with nobody + // attached — an uncaughtException — and the `listen` callback never + // fires, so this promise never settles. + httpServer.once('error', onError) + httpServer.listen(port, bindHost, () => { + httpServer.removeListener('error', onError) + resolve() + }) + }) + } + catch (error) { + // The WS transport is already attached above, so tear it down before + // surfacing the failure rather than leaking it and its peers. + await closeWs().catch(() => {}) + throw diagnostics.DF0052({ + host: bindHost, + port, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + } + + const address = httpServer.address() + const resolvedPort = typeof address === 'object' && address ? address.port : port + const origin = normalizeHttpServerUrl(bindHost, resolvedPort) + const internal = getInternalContext(context) + const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ''}` + internal.wsEndpoint = { url: wsUrl } + + function connectionMeta(): ConnectionMeta { + const jsonSerializableMethods: string[] = [] + for (const def of rpcHost.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + return { backend: 'websocket', websocket: { path: options.path }, jsonSerializableMethods } + } + + return { + origin, + port: resolvedPort, + app, + ws, + rpcGroup, + connectionMeta, + async close() { + await closeWs() + if (ownsHttpServer) + await new Promise(r => httpServer.close(() => r())) + if (getInternalContext(context).wsEndpoint?.url === wsUrl) + getInternalContext(context).wsEndpoint = undefined + }, + } +} /** * How the instance's RPC socket is bound: @@ -82,6 +222,12 @@ export interface CreateInstanceShellOptions Promise + /** + * Publish this instance in the global registry (`~/.devframe/instances/`) + * once its public origin is known — a dynamic import so the registry code + * stays out of instances that opt out. Omit to skip registration. + */ + register?: InstanceRegisterConfig /** Create the context and mount everything that must precede the transport. */ init: (api: InstanceShellApi) => Promise> /** Mount the routes that describe the resolved transport (discovery, SPA). */ @@ -90,6 +236,43 @@ export interface CreateInstanceShellOptions never } +/** + * The identity a shell needs to publish itself in the global instance + * registry — the parts it can't derive on its own. The shell fills in + * `pid` / `origin` / `port` / `basePath` / `mcp` / `startedAt` once the + * origin resolves, then merges {@link InstanceRegisterConfig.overrides} last. + */ +export interface InstanceRegisterConfig { + /** Definition id (or a synthetic one for a hub). */ + id: string + /** Display name. */ + name?: string + /** Working directory the instance runs from. Default: `process.cwd()`. */ + rootDir?: string + /** Fields overriding the shell-derived record (from the public option's object form). */ + overrides?: Partial +} + +/** + * Translate the public `register?: boolean | Partial` + * option into a shell {@link InstanceRegisterConfig}, or `undefined` when + * registration is opted out. The object form supplies record overrides on top + * of the caller-provided identity defaults. + */ +export function resolveInstanceRegister( + option: boolean | Partial | undefined, + defaults: { id: string, name?: string, rootDir?: string }, +): InstanceRegisterConfig | undefined { + if (!option) + return undefined + return { + id: defaults.id, + ...(defaults.name !== undefined ? { name: defaults.name } : {}), + ...(defaults.rootDir !== undefined ? { rootDir: defaults.rootDir } : {}), + ...(typeof option === 'object' ? { overrides: option } : {}), + } +} + /** Live internals the first-party adapters read off an instance. */ export interface InstanceShellInternals { readonly started?: StartedServer @@ -168,12 +351,50 @@ export function createInstanceShell( bannerPrinted = true authHandler.printBanner() } + + let meta: ConnectionMeta | undefined + let registration: DevframeInstanceRegistration | undefined + let registerPromise: Promise | undefined + /** + * Publish the instance in the global registry the moment both its origin + * and connection meta are known — at init end for a pinned origin, or on + * the first request for a derived one. Registration never throws (the + * registry writer degrades to a coded warning), so failures never surface. + */ + function maybeRegister(): void { + const cfg = options.register + const origin = currentOrigin() + if (!cfg || registerPromise || !origin || !meta) + return + const resolvedMeta = meta + registerPromise = import('./instance-registry').then(({ registerDevframeInstance }) => { + let port = 0 + try { + const url = new URL(origin) + port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80) + } + catch {} + registration = registerDevframeInstance({ + pid: process.pid, + port, + origin, + basePath: base, + id: cfg.id, + ...(cfg.name !== undefined ? { name: cfg.name } : {}), + rootDir: cfg.rootDir ?? process.cwd(), + mcp: resolvedMeta.mcp ? { path: joinURL(base, resolvedMeta.mcp.path) } : null, + startedAt: Date.now(), + ...cfg.overrides, + }) + }).catch(() => {}) + } + function noteOrigin(origin: string): void { derivedOrigin ??= origin maybePrintBanner() + maybeRegister() } - let meta: ConnectionMeta | undefined let started: StartedServer | undefined let transport: WsRpcTransport | undefined /** The `unbound` tier's resolved auth, held until its transport is built. */ @@ -212,7 +433,7 @@ export function createInstanceShell( */ async function startSidecar(auth: boolean | DevframeAuthHandler): Promise { const sidecarHost = options.host ?? 'localhost' - const start = (port: number): Promise => startHttpAndWs({ + const start = (port: number): Promise => bindHttpAndWs({ context: ctx, host: sidecarHost, port, @@ -257,8 +478,9 @@ export function createInstanceShell( else if (tier === 'server') { // Shared upgrade on the host's own server at `` — zero // extra ports, proxy/HTTPS friendly. - started = await startHttpAndWs({ + started = await bindHttpAndWs({ context: ctx, + host: options.host ?? 'localhost', port: 0, server: options.server, path: routePath, @@ -290,8 +512,10 @@ export function createInstanceShell( await options.mount?.(ctx, meta, api) - // A pinned origin means the banner needn't wait for a first request. + // A pinned origin means the banner and registry record needn't wait for a + // first request. maybePrintBanner() + maybeRegister() } const initPromise = init() @@ -394,7 +618,7 @@ export function createInstanceShell( /** * Publish the socket's absolute URL on the context, so surfaces that hand * out a complete endpoint (the hub's remote docks) work on this tier too. - * `startHttpAndWs` does the same for the tiers it owns. + * {@link bindHttpAndWs} does the same for the tiers it owns. */ function publishWsEndpoint(server: NodeHttpServer): void { const record = (): void => { @@ -446,6 +670,8 @@ export function createInstanceShell( attach, async close() { await initPromise.catch(() => {}) + await registerPromise?.catch(() => {}) + registration?.unregister() await dispose?.() await started?.close() await transportPromise?.then(live => live.close()).catch(() => {}) diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts index d75f2774..4b3ebb33 100644 --- a/packages/devframe/src/node/rpc-core.ts +++ b/packages/devframe/src/node/rpc-core.ts @@ -9,15 +9,18 @@ import { diagnostics } from './diagnostics' export interface CreateContextRpcServerOptions { context: DevframeNodeContext - /** See `StartHttpAndWsOptions.auth` — same contract, transport-agnostic. */ + /** + * Auth intent: `true`/omitted gates by default, `false` opts out (auto-trust + * handshake shim), a {@link DevframeAuthHandler} installs a custom scheme. + */ auth?: boolean | DevframeAuthHandler - /** See `StartHttpAndWsOptions.authorize`. */ + /** Lower-level per-call gate by method name and session, without a full handler. */ authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean - /** See `StartHttpAndWsOptions.onPeerConnect`. */ + /** Called once per new WS connection, right after its session is created. */ onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void - /** See `StartHttpAndWsOptions.onPeerDisconnect`. */ + /** Called once per closed WS connection, after the transport's disconnect bookkeeping. */ onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void - /** See `StartHttpAndWsOptions.rpcOptions`. */ + /** Forwarded verbatim to birpc's `rpcOptions` so a host keeps seeing RPC failures. */ rpcOptions?: Pick< EventOptions, 'onFunctionError' | 'onGeneralError' @@ -39,8 +42,9 @@ export interface ContextRpcServer { /** * Bind a devframe context's registered RPC functions to a birpc group, - * transport-agnostically — the shared core under `startHttpAndWs` (Node - * http + WS) and the Bun fetch-upgrade tier of `createHandler`. + * transport-agnostically — the shared core under the instance shell's own + * HTTP+WS binding (Node http + WS) and the Bun fetch-upgrade tier of + * `createHandler`. * * Owns everything about serving RPC that is independent of *how* peers * connect: the auth handler's function registration, the @@ -73,7 +77,7 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions): { rpcOptions: { // Forwarded as-is so a host with its own structured diagnostics - // keeps seeing RPC failures; see `StartHttpAndWsOptions.rpcOptions`. + // keeps seeing RPC failures. onFunctionError: options.rpcOptions?.onFunctionError, onGeneralError: options.rpcOptions?.onGeneralError, // Wrap each RPC handler in an AsyncLocalStorage context so diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts deleted file mode 100644 index 451d7a98..00000000 --- a/packages/devframe/src/node/server.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { BirpcGroup, EventOptions } from 'birpc' -import type { Peer } from 'crossws' -import type { NodeAdapter } from 'crossws/adapters/node' -import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' -import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' -import type { Server as NodeHttpServer } from 'node:http' -import type { DevframeAuthHandler } from './auth' -import type { RpcFunctionsHostImpl } from './host-functions' -import { createServer } from 'node:http' -import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' -import { H3, toNodeHandler } from 'h3' -import { diagnostics } from './diagnostics' -import { getInternalContext } from './hub-internals/context' -import { createContextRpcServer } from './rpc-core' -import { formatHostForUrl, normalizeHttpServerUrl } from './utils' - -export interface StartHttpAndWsOptions { - context: DevframeNodeContext - host?: string - port: number - /** - * Optional h3 app to mount on. When omitted a fresh one is created; - * when provided, callers can add their own routes (static handlers, - * auth middleware, etc.) first. - */ - app?: H3 - /** - * Bind the WS endpoint to a single upgrade route (e.g. `/__ws`) instead of - * claiming every upgrade on the port. This lets the socket share a server - * with other upgrade handlers (Vite HMR, a host framework's own sockets) - * and is what the SPA's `__connection.json` points at. When omitted, the WS - * server handles every upgrade on the port (legacy behaviour). - */ - path?: string - /** - * Bind the WS endpoint on its own port instead of sharing the HTTP server's. - * The HTTP/SPA server still listens on `port`; the socket gets a dedicated - * `ws` server on `wsPort` (same `host`). Use this for the "different port" - * connection scenario. Ignored when a `server` is supplied. - */ - wsPort?: number - /** - * Mount the WS endpoint onto an existing HTTP server, sharing its port, - * rather than creating and listening on a fresh one. Use this to embed - * devframe's RPC socket inside a host server (e.g. a Vite dev server) — pair - * it with `path` so it coexists with the host's routes. The caller owns the - * server's lifecycle: {@link StartedServer.close} detaches devframe's upgrade - * listener but leaves the host server running. When set, `host`/`port` are - * only used to report the resolved origin. - */ - server?: NodeHttpServer - /** - * Destroy upgrade requests on a shared `server` that don't match `path`, - * instead of leaving them for the caller's other upgrade handlers. Enable - * when the caller owns the server outright but composes it through the - * shared-`server` path (e.g. `createDevServer` over `createHandler`). - * Defaults to whether this call created the server itself. - */ - destroyUnmatched?: boolean - /** - * Authentication for the server: - * - * - `true` (default) — no gate; every registered method is callable - * regardless of trust (today's behavior, unchanged). - * - `false` — the RPC server is started without a trust handshake. - * Intended for single-user localhost tools where an auth round-trip - * would only get in the way. A noop `anonymous:devframe:auth` handler - * is registered so the browser client's unconditional handshake call - * succeeds and auto-trusts. - * - A {@link DevframeAuthHandler} (e.g. from - * `devframe/recipes/interactive-auth`'s `createInteractiveAuth`) — - * registers its `rpcFunctions`, wires its `authorize` as the resolver - * gate, and wires its `onConnect` on every new peer. This is the - * fully-authenticated server: an untrusted caller can only reach - * `anonymous:`-prefixed methods (see `isAnonymousRpcMethod`). - */ - auth?: boolean | DevframeAuthHandler - /** - * Lower-level escape hatch: gate individual RPC calls by method name and - * session without a full {@link DevframeAuthHandler}. Ignored when `auth` - * is a handler object (its own `authorize` is used); combine with `auth: - * true` to layer a custom policy on top of an otherwise ungated server. - */ - authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean - /** - * Called once per new WS connection, right after its session is created - * (before any RPC call is dispatched). Runs after the auth handler's own - * `onConnect` (when `auth` is a {@link DevframeAuthHandler}), so it can - * observe — but not override — the connect-time trust decision. - */ - onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void - /** - * Called once per closed WS connection, right after the transport's own - * disconnect bookkeeping runs. Unlike {@link onPeerConnect} this receives - * the raw session meta, not a wrapped session — by the time a peer - * disconnects there is no live RPC client left to attach. - */ - onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void - /** - * Forwarded verbatim to the internal `createRpcServer`'s birpc - * `rpcOptions`, alongside the resolver `startHttpAndWs` installs for - * auth/session wiring. Use this so a host that owns its own structured - * diagnostics (e.g. a coded error reporter) keeps seeing RPC failures - * instead of them being silently absorbed by delegating to - * `startHttpAndWs`. Returning `true` from either callback suppresses - * birpc's own error response to the caller — see birpc's - * `EventOptions` for the full contract. - */ - rpcOptions?: Pick< - EventOptions, - 'onFunctionError' | 'onGeneralError' - > - /** - * Extra origins to accept on the WS upgrade beyond the loopback default - * (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less request from a - * native client). Add your LAN/tunnel origin here when reaching the tool - * from another host. Pass `false` to disable origin checking entirely - * (not recommended). Default: loopback-only. - */ - allowedOrigins?: readonly string[] | WsOriginRegistry | false - /** - * Called once the WS server is bound so callers can mount static - * handlers whose origin depends on the resolved port, or print their - * own startup banner. Devframe does not print one itself. - */ - onReady?: (info: { origin: string, port: number, app: H3 }) => void | Promise - /** - * Called for any error the HTTP server devframe owns emits after it starts - * listening — e.g. a transient `EMFILE` while accepting a connection. - * Without it such an error has no listener and crashes the process. - * - * Applies only to a server devframe created itself. When `server` is - * supplied the caller owns that object and attaches to it directly, so - * devframe leaves its error handling alone. - */ - onServerError?: (error: Error) => void -} - -export interface StartedServer { - /** Listening origin, e.g. `http://localhost:9999`. */ - origin: string - port: number - app: H3 - /** The crossws node adapter driving the RPC socket (connected peers, pub/sub). */ - ws: NodeAdapter - rpcGroup: BirpcGroup - /** - * The {@link ConnectionMeta} descriptor for this server — the same shape - * a `__connection.json` route should serve so a devframe client's - * `resolveWsUrl` can dial back in. Reflects the `path` / `wsPort` this - * server was started with and the `jsonSerializable` methods currently - * registered on `context.rpc`. - */ - connectionMeta: () => ConnectionMeta - close: () => Promise -} - -/** - * Compose an h3 + WebSocket server for a devframe context. The RPC - * group is bound to `context.rpc.functions`; the WS endpoint lives on - * the same port as the HTTP server. - */ -export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { - const { context, port } = options - const bindHost = options.host ?? 'localhost' - const app = options.app ?? new H3() - // When the caller supplies a server we share it (and never close it); - // otherwise we own a fresh one bound to `app`. - const ownsHttpServer = !options.server - const httpServer = options.server ?? createServer(toNodeHandler(app)) - const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl - - // Transport-agnostic RPC core: auth wiring, session resolver, and the - // peer lifecycle handlers the WS transport below plugs into. - const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ - context, - auth: options.auth, - authorize: options.authorize, - onPeerConnect: options.onPeerConnect, - onPeerDisconnect: options.onPeerDisconnect, - rpcOptions: options.rpcOptions, - }) - - // A dedicated WS port (the "different port" scenario) only applies when we - // own the HTTP server — a shared host server already dictates the port. - const separateWsPort = ownsHttpServer && options.wsPort != null && options.wsPort !== port - ? options.wsPort - : undefined - const { ws, close: closeWs } = attachWsRpcTransport(rpcGroup, { - // Share the HTTP server unless a separate WS port is requested, in which - // case bind a standalone WS server on that port. - ...(separateWsPort != null - ? { port: separateWsPort, host: bindHost } - : { server: httpServer }), - path: options.path, - // When we own the server nothing else handles its upgrades, so reject - // off-route attempts promptly. A shared (caller-owned) server may host - // other sockets, so leave non-matching upgrades for them — unless the - // caller says otherwise. - destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer, - allowedOrigins: options.allowedOrigins, - onConnected, - onDisconnected, - }) - - // Only start listening on a server we created. A shared server is already - // (or about to be) listening under the caller's control. - if (ownsHttpServer) { - try { - await new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error) - // Without this listener a failed bind emits `error` with nobody - // attached — an uncaughtException — and the `listen` callback never - // fires, so this promise never settles. - httpServer.once('error', onError) - httpServer.listen(port, bindHost, () => { - httpServer.removeListener('error', onError) - resolve() - }) - }) - } - catch (error) { - // The WS transport is already attached above, so tear it down before - // surfacing the failure rather than leaking it and its peers. - await closeWs().catch(() => {}) - throw diagnostics.DF0052({ - host: bindHost, - port, - reason: error instanceof Error ? error.message : String(error), - cause: error, - }) - } - // Attached only now that the bind has already succeeded — this never - // changes bind-time crash/hang semantics, only what happens afterward. - if (options.onServerError) - httpServer.on('error', options.onServerError) - } - - const address = httpServer.address() - const resolvedPort = typeof address === 'object' && address ? address.port : port - // Advertise a dialable origin: a wildcard bind host (`0.0.0.0` / `::`) is not - // reachable from a browser, so the URL a client opens falls back to loopback - // even though the socket keeps listening on every interface. - const origin = normalizeHttpServerUrl(bindHost, resolvedPort) - const internal = getInternalContext(context) - // Record the full WS URL (including the bound route) so consumers like the - // hub docks host can hand remote iframes a complete endpoint. A dedicated WS - // port is reflected here so the URL stays dialable. - const wsPortForUrl = separateWsPort ?? resolvedPort - const wsUrl = `ws://${formatHostForUrl(bindHost)}:${wsPortForUrl}${options.path ?? ''}` - internal.wsEndpoint = { - url: wsUrl, - } - - if (options.onReady) - await options.onReady({ origin, port: resolvedPort, app }) - - function connectionMeta(): ConnectionMeta { - const jsonSerializableMethods: string[] = [] - for (const def of rpcHost.definitions.values()) { - if (def.jsonSerializable === true) - jsonSerializableMethods.push(def.name) - } - const websocket = separateWsPort != null - ? { port: separateWsPort, path: options.path } - : { path: options.path } - return { backend: 'websocket', websocket, jsonSerializableMethods } - } - - return { - origin, - port: resolvedPort, - app, - ws, - rpcGroup, - connectionMeta, - async close() { - // Detaches the upgrade listener first (so a shared host server stops - // routing new connections to us while other handlers keep working), - // force-terminates every peer for deterministic teardown, and closes - // any dedicated-port WS server the transport created. - await closeWs() - // Leave a caller-owned server running — we only created (and listen on) - // our own. - if (ownsHttpServer) - await new Promise(r => httpServer.close(() => r())) - if (getInternalContext(context).wsEndpoint?.url === wsUrl) - getInternalContext(context).wsEndpoint = undefined - }, - } -} diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index 84f6994b..7fd7e529 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -6,10 +6,10 @@ import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' import { describe, expect, it } from 'vitest' +import { serveTestContext } from '../../../../../tests/helpers/serve-test-context' import { getTempAuthCode } from '../../node/auth/state' import { createHostContext } from '../../node/context' import { getInternalContext } from '../../node/hub-internals/context' -import { startHttpAndWs } from '../../node/server' import { createInteractiveAuth } from '../interactive-auth' function makeHost(storageDir: string): DevframeHost { @@ -42,7 +42,7 @@ async function startAuthenticatedServer( const host = '127.0.0.1' const port = await getPort({ port: 0, host }) - const server = await startHttpAndWs({ + const server = await serveTestContext({ context, host, port, diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 213555f2..c9bfc4f9 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -53,7 +53,7 @@ function defaultBanner(info: { code: string, url: string }): void { * auth.printBanner() * * // wire `auth.authorize` / `auth.onConnect` into your transport, or pass - * // the whole handler to `startHttpAndWs({ auth, ... })`. + * // the whole handler to `initDevframe` / `initHub` via their `auth` option. * ``` */ export function createInteractiveAuth( diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 0f7e9390..6306fffd 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -130,7 +130,7 @@ export interface DevframeCliOptions { * The `--no-auth` CLI flag maps here for one-off runs. * - A {@link DevframeAuthHandler} — a custom handler (e.g. a tuned * `createInteractiveAuth`, or an entirely different scheme) passed - * straight through to `startHttpAndWs`. + * straight through to the RPC transport binding. * * Hosted adapters (`vite`, `embedded`) ignore this and defer to the host's * auth; `@vitejs/devtools` honors the equivalent `devtools.clientAuth`. @@ -201,7 +201,7 @@ export interface DevframeCliOptions { /** * Default dock attributes for the iframe entry a hub synthesizes when it * mounts this devframe. Framework-neutral metadata only — the hub layer - * (`mountDevframe`) merges these beneath its per-mount `dock` overrides, + * (`ctx.install`) merges these beneath its per-mount `dock` overrides, * which in turn sit beneath the locked, derived `id` / `type` / `url`. * * Every field is optional. `title` / `icon` default to the definition's @@ -283,7 +283,7 @@ export interface DevframeDefinition { icon?: string | { light: string, dark: string } /** * Default dock attributes applied when a hub mounts this devframe as an - * iframe dock entry. Consulted only by hub adapters (`mountDevframe`), + * iframe dock entry. Consulted only by the hub install path (`ctx.install`), * which merge these beneath the per-mount `dock` overrides; standalone * adapters (`cli` / `spa` / `build`) ignore it. * @@ -299,7 +299,7 @@ export interface DevframeDefinition { /** * How a hub reacts when another devframe sharing this one's `id` is * mounted onto the same hub. Consulted only by hub adapters - * (`mountDevframe`); standalone adapters (`cli` / `spa` / `build`) + * (`ctx.install`); standalone adapters (`cli` / `spa` / `build`) * ignore it. * * @default 'warn' diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index c5406a1f..0555f227 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -22,7 +22,7 @@ export interface DevframeHost { * base, so a devframe SPA mounted there can discover the RPC/WS endpoint * via `connectDevframe()`'s relative `./__connection.json` fetch. * - * Called by `mountDevframe` for each mounted devframe (alongside + * Called by `ctx.install` for each mounted devframe (alongside * `mountStatic`). Without it, an embedded SPA can only discover the * endpoint by inheriting it from a same-origin parent window — which fails * for cross-origin or sandboxed iframes. Implementations serve the same diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index 045b3a89..77146768 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -183,7 +183,7 @@ export interface RpcStreamingHost { /** * Adapters call this when a session disconnects so the host can drop * subscribers and abort orphaned streams. Most users do not need this; - * it's wired by `startHttpAndWs` automatically. + * it's wired by the RPC server binding automatically. * * @internal */ diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index 5ba5e2e1..1b87e723 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -2,10 +2,10 @@ import type { DevframeDockEntry } from '../../types/docks' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { startHttpAndWs } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { getInternalContext } from 'devframe/node/hub-internals' import { describe, expect, it, vi } from 'vitest' +import { serveTestContext } from '../../../../../tests/helpers/serve-test-context' import { createHubContext } from '../context' function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) { @@ -60,7 +60,7 @@ describe('createHubContext dock activation', () => { }) }) -describe('startHttpAndWs remote endpoint metadata', () => { +describe('served context remote endpoint metadata', () => { it('sets and clears the internal websocket endpoint', async () => { const context = await createHostContext({ cwd: process.cwd(), @@ -68,7 +68,7 @@ describe('startHttpAndWs remote endpoint metadata', () => { host: createHost(), }) - const started = await startHttpAndWs({ + const started = await serveTestContext({ context, host: '127.0.0.1', port: 0, diff --git a/packages/hub/src/node/__tests__/mount-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts similarity index 77% rename from packages/hub/src/node/__tests__/mount-devframe.test.ts rename to packages/hub/src/node/__tests__/install-devframe.test.ts index 4c624be1..950c0d54 100644 --- a/packages/hub/src/node/__tests__/mount-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -6,10 +6,10 @@ import { join } from 'node:path' import { defineDevframe } from 'devframe' import { afterEach, describe, expect, it, vi } from 'vitest' import { DevframeDocksHost } from '../host-docks' -import { mountDevframe } from '../mount-devframe' +import { installDevframe } from '../install-devframe' function createContext(): DevframeHubContext { - const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-mount-')) + const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-install-')) const context = { host: { mountStatic: () => {}, @@ -21,6 +21,9 @@ function createContext(): DevframeHubContext { }, } as unknown as DevframeHubContext context.docks = new DevframeDocksHost(context) + // `createHubContext` wires this; the hand-built fake context here does the + // same so the tests drive the public `ctx.install` surface. + context.install = (devframe, options) => installDevframe(context, devframe, options) return context } @@ -39,7 +42,7 @@ function makeDevframe( }) } -describe('mountDevframe', () => { +describe('ctx.install', () => { afterEach(() => { vi.restoreAllMocks() }) @@ -47,7 +50,7 @@ describe('mountDevframe', () => { it('registers an iframe dock derived from the definition and runs setup', async () => { const ctx = createContext() const setup = vi.fn() - await mountDevframe(ctx, makeDevframe({ setup })) + await ctx.install(makeDevframe({ setup })) expect(ctx.docks.views.size).toBe(1) const entry = ctx.docks.views.get('demo') @@ -57,7 +60,7 @@ describe('mountDevframe', () => { it('applies the definition-level dock defaults to the synthesized entry', async () => { const ctx = createContext() - await mountDevframe(ctx, makeDevframe({ + await ctx.install(makeDevframe({ dock: { category: 'framework', defaultOrder: 5, when: 'clientType == embedded' }, })) @@ -73,7 +76,7 @@ describe('mountDevframe', () => { it('applies the definition-level dock `visibility` default to the synthesized entry, independent of `when`', async () => { const ctx = createContext() - await mountDevframe(ctx, makeDevframe({ + await ctx.install(makeDevframe({ dock: { when: 'clientType == embedded', visibility: 'false' }, })) @@ -86,11 +89,7 @@ describe('mountDevframe', () => { it('lets per-mount dock overrides win over the definition dock defaults', async () => { const ctx = createContext() - await mountDevframe( - ctx, - makeDevframe({ dock: { category: 'framework', defaultOrder: 5 } }), - { dock: { category: 'app' } }, - ) + await ctx.install(makeDevframe({ dock: { category: 'framework', defaultOrder: 5 } }), { dock: { category: 'app' } }) expect(ctx.docks.views.get('demo')).toMatchObject({ category: 'app', @@ -103,8 +102,8 @@ describe('mountDevframe', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const setup = vi.fn() - await mountDevframe(ctx, makeDevframe({ setup })) - await mountDevframe(ctx, makeDevframe({ setup, name: 'Demo Again' })) + await ctx.install(makeDevframe({ setup })) + await ctx.install(makeDevframe({ setup, name: 'Demo Again' })) expect(ctx.docks.views.size).toBe(1) expect(ctx.docks.views.get('demo')).toMatchObject({ title: 'Demo' }) @@ -118,8 +117,8 @@ describe('mountDevframe', () => { const setup = vi.fn() const strategy: DevframeDuplicationStrategy = 'silent' - await mountDevframe(ctx, makeDevframe({ setup, duplicationStrategy: strategy })) - await mountDevframe(ctx, makeDevframe({ setup, duplicationStrategy: strategy })) + await ctx.install(makeDevframe({ setup, duplicationStrategy: strategy })) + await ctx.install(makeDevframe({ setup, duplicationStrategy: strategy })) expect(ctx.docks.views.size).toBe(1) expect(setup).toHaveBeenCalledTimes(1) @@ -131,8 +130,8 @@ describe('mountDevframe', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const def = makeDevframe({ duplicationStrategy: 'throw' }) - await mountDevframe(ctx, def) - await expect(mountDevframe(ctx, def)).rejects.toThrow(/already mounted/) + await ctx.install(def) + await expect(ctx.install(def)).rejects.toThrow(/already mounted/) expect(ctx.docks.views.size).toBe(1) }) @@ -142,7 +141,7 @@ describe('mountDevframe', () => { ;(ctx.host as { mountConnectionMeta?: unknown }).mountConnectionMeta = mountConnectionMeta const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } })) + await ctx.install(makeDevframe({ cli: { distDir: '/tmp/demo-dist' } })) expect(mountConnectionMeta).toHaveBeenCalledWith('/__demo/') expect(warn).not.toHaveBeenCalled() @@ -152,7 +151,7 @@ describe('mountDevframe', () => { const ctx = createContext() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } })) + await ctx.install(makeDevframe({ cli: { distDir: '/tmp/demo-dist' } })) expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0].join(' ')).toContain('DF8106') @@ -162,9 +161,9 @@ describe('mountDevframe', () => { const ctx = createContext() const setup = vi.fn() - await mountDevframe(ctx, makeDevframe({ setup, duplicationStrategy: 'duplicate' })) - await mountDevframe(ctx, makeDevframe({ setup, duplicationStrategy: 'duplicate' })) - await mountDevframe(ctx, makeDevframe({ setup, duplicationStrategy: 'duplicate' })) + await ctx.install(makeDevframe({ setup, duplicationStrategy: 'duplicate' })) + await ctx.install(makeDevframe({ setup, duplicationStrategy: 'duplicate' })) + await ctx.install(makeDevframe({ setup, duplicationStrategy: 'duplicate' })) expect([...ctx.docks.views.keys()]).toEqual(['demo', 'demo-2', 'demo-3']) expect(ctx.docks.views.get('demo-2')).toMatchObject({ type: 'iframe', url: '/__demo-2/' }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index adf86802..870ffed9 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -1,15 +1,17 @@ import type { CreateHostContextOptions } from 'devframe/node' -import type { DevframeHost, DevframeNodeContext } from 'devframe/types' +import type { DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types' import type { DevframeCommandsHost } from '../types/commands' import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks' import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesHost } from '../types/messages' import type { DevframeTerminalsHost } from '../types/terminals' +import type { InstallDevframeOptions } from './install-devframe' 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 { installDevframe } from './install-devframe' import { builtinHubRpcDeclarations } from './rpc-builtins' declare module 'devframe/types' { @@ -100,6 +102,14 @@ export interface DevframeHubContext extends DevframeNodeContext { terminals: DevframeTerminalsHost messages: DevframeMessagesHost commands: DevframeCommandsHost + /** + * Install a {@link DevframeDefinition} into this hub: serve its SPA at the + * resolved base, synthesize an iframe dock from its metadata, and run its + * `setup(ctx)`. The imperative counterpart to `initHub`'s declarative + * `devframes` list — call it from a hub host's `configure(ctx)`, or wherever + * you hold the context, to plug an extra devframe in. + */ + install: (devframe: DevframeDefinition, options?: InstallDevframeOptions) => Promise } /** @@ -134,6 +144,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis context.terminals = terminals context.messages = messages context.commands = commands + context.install = (devframe, options) => installDevframe(context, devframe, options) await docks.init() diff --git a/packages/hub/src/node/index.ts b/packages/hub/src/node/index.ts index c5160ded..ab5b4845 100644 --- a/packages/hub/src/node/index.ts +++ b/packages/hub/src/node/index.ts @@ -3,6 +3,6 @@ export * from './host-commands' export * from './host-docks' export * from './host-messages' export * from './host-terminals' -export * from './mount-devframe' +export type { InstallDevframeOptions } from './install-devframe' export * from './rpc-builtins' export * from './utils' diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index ae19fa2b..d2cbff26 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -1,3 +1,4 @@ +import type { DevframeInstanceRecord } from 'devframe/internal' import type { DevframeAuthHandler } from 'devframe/node/auth' import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' import type { ConnectionMeta, DevframeDefinition, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types' @@ -6,24 +7,23 @@ import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from ' import type { Duplex } from 'node:stream' import type { ClientScriptEntry } from '../types/docks' import type { CreateHubContextOptions, DevframeHubContext } from './context' -import type { MountDevframeOptions } from './mount-devframe' +import type { InstallDevframeOptions } from './install-devframe' import { readFile } from 'node:fs/promises' import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from 'devframe/constants' -import { createH3DevframeHost, createInstanceShell } from 'devframe/internal' +import { createH3DevframeHost, createInstanceShell, resolveInstanceRegister } from 'devframe/internal' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' import { cleanDoubleSlashes, joinURL, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo' import { createHubContext } from './context' import { diagnostics } from './diagnostics' -import { mountDevframe } from './mount-devframe' /** A `devframes` entry with per-mount dock customization. */ export interface HubDevframeEntry { devframe: DevframeDefinition /** Per-mount overrides for the auto-synthesized iframe dock entry. */ - dock?: MountDevframeOptions['dock'] + dock?: InstallDevframeOptions['dock'] } type Thenable = T | Promise @@ -139,7 +139,7 @@ export interface InitHubOptions { rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations'] /** * Bring your own hub context instead of `devframes` — for hosts that - * assemble `createHubContext` + `mountDevframe` themselves (with their own + * assemble `createHubContext` + `ctx.install` themselves (with their own * `DevframeHost` serving the frames). The instance then serves only the * hub-level endpoints (`__connection.json`, `__index.json`, * `__client-imports.js`, the WS transport, MCP, and the `ui` slot); @@ -191,6 +191,16 @@ export interface InitHubOptions { * from the first request when omitted. */ origin?: string | (() => string) + /** + * Publish this hub in the global instance registry + * (`~/.devframe/instances/`) so discovery tooling (`devframe connect`, the + * inspect plugin's Instances tab) lists it like any standalone devframe. + * Registration is a dynamic import that fires once the public origin + * resolves and is torn down on {@link HubInstance.close}. Defaults to off; + * pass `true` to enable, or an object to override individual record fields + * (`id`, `name`, `basePath`, …). + */ + register?: boolean | Partial /** Working directory for the hub context. Default: `process.cwd()`. */ cwd?: string /** Override where persisted devframe state lives. */ @@ -323,6 +333,11 @@ export function initHub(options: InitHubOptions): HubInstance { ws: options.ws, allowedOrigins: options.allowedOrigins, destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, + register: resolveInstanceRegister(options.register, { + id: options.name ?? 'devframes-hub', + ...(options.name !== undefined ? { name: options.name } : {}), + rootDir: cwd, + }), // One meta document is served from the hub base *and* from every frame // base, so the advertised WS path has to be base-absolute to resolve to // the same socket from any depth. @@ -386,7 +401,7 @@ export function initHub(options: InitHubOptions): HubInstance { if (!/^[\w.-]+$/.test(def.id)) throw diagnostics.DF8004({ id: def.id }) const frameBase = withTrailingSlash(joinURL(base, def.id)) - await mountDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) }) + await ctx.install(def, { base: frameBase, ...(dock ? { dock } : {}) }) frames.push({ id: def.id, base: frameBase, title: def.name }) } diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/install-devframe.ts similarity index 83% rename from packages/hub/src/node/mount-devframe.ts rename to packages/hub/src/node/install-devframe.ts index 4b3690a7..87f67fe5 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -5,7 +5,7 @@ import { resolveBasePath } from 'devframe/node/hub-internals' import { resolve } from 'pathe' import { diagnostics } from './diagnostics' -export interface MountDevframeOptions { +export interface InstallDevframeOptions { /** * Mount path override. Defaults to `d.basePath` or `/__${d.id}/`. */ @@ -37,19 +37,21 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId } /** - * 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-neutral primitive backing {@link DevframeHubContext.install} — + * installs 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)`. Reach for it through `ctx.install(devframe)` rather than + * calling it directly. * - * 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. + * Framework kits wrap `ctx.install` 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( +export async function installDevframe( ctx: DevframeHubContext, d: DevframeDefinition, - options: MountDevframeOptions = {}, + options: InstallDevframeOptions = {}, ): Promise { const strategy = d.duplicationStrategy ?? 'warn' const isDuplicate = ctx.docks.views.has(d.id) diff --git a/plugins/a11y/demo/server.mjs b/plugins/a11y/demo/server.mjs index 77123800..26ff52e3 100644 --- a/plugins/a11y/demo/server.mjs +++ b/plugins/a11y/demo/server.mjs @@ -22,9 +22,7 @@ import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' -import { createHostContext } from 'devframe/node' +import { initDevframe } from 'devframe/initiate' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3, toNodeHandler } from 'h3' @@ -82,22 +80,20 @@ async function main() { if (mode === 'dev') { const origin = `http://localhost:${port}` - const h3Host = createH3DevframeHost({ + // 3a. `initDevframe` runs setup, serves the panel SPA + `__connection.json` + // on our shared app, and binds the WS RPC upgrade onto our server. + const server = createServer(toNodeHandler(app)) + const instance = initDevframe(devframe, { + base: basePath, + distDir: panelDir, + app, + server, + host: bindHost, origin, - appName: devframe.id, - mount: (base, dir) => mountStaticHandler(app, base, dir), + auth: false, }) - const ctx = await createHostContext({ cwd: ROOT, mode: 'dev', host: h3Host }) - await devframe.setup(ctx) - - // 3a. Connection meta (must precede the catch-all static mount) + WS RPC. - app.use( - `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`, - () => ({ backend: 'websocket', websocket: port }), - ) - mountStaticHandler(app, basePath, panelDir) - - await startHttpAndWs({ context: ctx, host: bindHost, port, app, auth: false }) + await new Promise(r => server.listen(port, bindHost, r)) + await instance.ready banner(origin) } else { diff --git a/plugins/a11y/tests/_utils.ts b/plugins/a11y/tests/_utils.ts index 679ef8c9..5c7d9a84 100644 --- a/plugins/a11y/tests/_utils.ts +++ b/plugins/a11y/tests/_utils.ts @@ -4,12 +4,13 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import devframe from '../src/index' const HERE = fileURLToPath(new URL('.', import.meta.url)) @@ -56,6 +57,6 @@ export async function startInspectorServer(): Promise { app.use(`${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, resolve(distDir)) - const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + const server = await serveTestContext({ context: ctx, host, port, app, auth: false }) return Object.assign(server, { basePath }) } diff --git a/plugins/assets/test/_utils.ts b/plugins/assets/test/_utils.ts index 407c8a3e..a1be1c14 100644 --- a/plugins/assets/test/_utils.ts +++ b/plugins/assets/test/_utils.ts @@ -6,13 +6,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' import { createRpcStreamingClientHost } from 'devframe/client' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import { createAssetsDevframe } from '../src/index' import { disposeAssetsWatcher } from '../src/node/index' @@ -50,7 +51,7 @@ export async function startAssetsServer( const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) await definition.setup(ctx) - const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + const server = await serveTestContext({ context: ctx, host, port, app, auth: false }) // The live file watcher started by `setupAssets` otherwise keeps the // process alive past the test run. diff --git a/plugins/data-inspector/src/inject/index.ts b/plugins/data-inspector/src/inject/index.ts index 5a8546e7..40d5132f 100644 --- a/plugins/data-inspector/src/inject/index.ts +++ b/plugins/data-inspector/src/inject/index.ts @@ -39,12 +39,14 @@ import type { DevframeHost, DevframeNodeContext } from 'devframe' import type { DataSourceEntry } from '../registry/index' import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import process from 'node:process' -import { startHttpAndWs } from 'devframe/internal' +import { createContextRpcServer } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' import { randomToken } from 'devframe/utils/crypto-token' import { getPort } from 'get-port-please' @@ -153,7 +155,26 @@ export async function exposeDataInspector(options: ExposeDataInspectorOptions = ? createInteractiveAuth(context, { clientAuthTokens: [token!], banner: () => {} }) : false - const handle = await startHttpAndWs({ context, port, auth }) + // A bare WS RPC endpoint — no SPA, no discovery routes — so the agent + // binds the still-public transport primitives directly rather than the + // full-instance factories. The socket claims every upgrade on the port, so + // the advertised endpoint stays the bare `ws://127.0.0.1:`. + const httpServer = createServer() + const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ context, auth }) + const { close: closeWs } = attachWsRpcTransport(rpcGroup, { + server: httpServer, + destroyUnmatched: true, + onConnected, + onDisconnected, + }) + await new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + httpServer.once('error', onError) + httpServer.listen(port, '127.0.0.1', () => { + httpServer.removeListener('error', onError) + resolve() + }) + }) const discoveryPath = join(cwd, AGENT_DISCOVERY_FILE) if (options.discoveryFile !== false) { @@ -185,7 +206,8 @@ export async function exposeDataInspector(options: ExposeDataInspectorOptions = } catch {} } - await handle.close() + await closeWs() + await new Promise(resolve => httpServer.close(() => resolve())) }, } } diff --git a/plugins/git/test/_utils.ts b/plugins/git/test/_utils.ts index 8955696c..aedb9c03 100644 --- a/plugins/git/test/_utils.ts +++ b/plugins/git/test/_utils.ts @@ -2,12 +2,13 @@ import type { DevframeNodeContext } from 'devframe' import type { StartedServer } from 'devframe/internal' import type { GitDevframeOptions } from '../src/index' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import { createGitDevframe } from '../src/index' export interface DashboardServer extends StartedServer { @@ -64,6 +65,6 @@ export async function startDashboardServer( app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, resolve(distDir)) - const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + const server = await serveTestContext({ context: ctx, host, port, app, auth: false }) return Object.assign(server, { basePath }) } diff --git a/plugins/inspect/test/_utils.ts b/plugins/inspect/test/_utils.ts index 74bf5291..04f01cd9 100644 --- a/plugins/inspect/test/_utils.ts +++ b/plugins/inspect/test/_utils.ts @@ -7,12 +7,13 @@ import process from 'node:process' import { createHubContext } from '@devframes/hub/node' import inspectDevframe from '@devframes/plugin-inspect' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { resolveBasePath } from 'devframe/node/hub-internals' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' const SPA_DIST = inspectDevframe.cli!.distDir! @@ -74,7 +75,7 @@ async function boot(options: BootOptions): Promise { app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, path.resolve(distDir)) - const server = await startHttpAndWs({ + const server = await serveTestContext({ context: ctx, host, port, diff --git a/plugins/messages/README.md b/plugins/messages/README.md index 658d3f59..e184d111 100644 --- a/plugins/messages/README.md +++ b/plugins/messages/README.md @@ -17,10 +17,9 @@ feed through `@devframes/hub`'s `listSince` delta API. ## Mount into a hub ```ts -import { mountDevframe } from '@devframes/hub/node' import messagesDevframe from '@devframes/plugin-messages' -await mountDevframe(hubContext, messagesDevframe) +await hubContext.install(messagesDevframe) ``` The hub's `ctx.messages` host feeds the panel live — every diff --git a/plugins/messages/src/diagnostics.ts b/plugins/messages/src/diagnostics.ts index aed2ca58..33c82a16 100644 --- a/plugins/messages/src/diagnostics.ts +++ b/plugins/messages/src/diagnostics.ts @@ -25,7 +25,7 @@ export const diagnostics = defineDiagnostics({ DP_MESSAGES_0001: { why: (p: { id: string }) => `"${p.id}" is mounted on a context without a hub messages host (\`ctx.messages\`) — its RPC surface stays registered but no-ops, so the panel will show an empty feed.`, - fix: 'Mount this devframe through a hub host (`@devframes/hub`\'s `createHubContext` + `mountDevframe`) to get a live message feed.', + fix: 'Mount this devframe through a hub host (`@devframes/hub`\'s `initHub`, or `createHubContext` + `ctx.install`) to get a live message feed.', }, }, }) diff --git a/plugins/messages/test/_utils.ts b/plugins/messages/test/_utils.ts index 9c92fded..05031f73 100644 --- a/plugins/messages/test/_utils.ts +++ b/plugins/messages/test/_utils.ts @@ -9,12 +9,13 @@ import process from 'node:process' import { createHubContext } from '@devframes/hub/node' import messagesDevframe from '@devframes/plugin-messages' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { resolveBasePath } from 'devframe/node/hub-internals' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' const SPA_DIST = messagesDevframe.cli!.distDir! @@ -77,7 +78,7 @@ async function boot(options: BootOptions): Promise { app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, path.resolve(distDir)) - const server = await startHttpAndWs({ + const server = await serveTestContext({ context: ctx, host, port, diff --git a/plugins/og/test/_utils.ts b/plugins/og/test/_utils.ts index 50cd0eae..fd71b58d 100644 --- a/plugins/og/test/_utils.ts +++ b/plugins/og/test/_utils.ts @@ -4,12 +4,13 @@ import path from 'node:path' import process from 'node:process' import { createOgDevframe } from '@devframes/plugin-og' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { resolveBasePath } from 'devframe/node/hub-internals' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' export async function testFetch(_url: string): Promise { return new Response(` @@ -51,6 +52,6 @@ export async function startOgServer(): Promise { await testDevframe.setup(ctx) app.use(`${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`, () => ({ backend: 'websocket', websocket: port })) mountStaticHandler(app, basePath, path.resolve(distDir)) - const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + const server = await serveTestContext({ context: ctx, host, port, app, auth: false }) return Object.assign(server, { basePath }) } diff --git a/plugins/terminals/test/_utils.ts b/plugins/terminals/test/_utils.ts index a896995b..710f104c 100644 --- a/plugins/terminals/test/_utils.ts +++ b/plugins/terminals/test/_utils.ts @@ -3,13 +3,14 @@ import type { StartedServer } from 'devframe/internal' import type { TerminalsOptions } from '../src/types' import process from 'node:process' import { createRpcStreamingClientHost } from 'devframe/client' -import { createH3DevframeHost, startHttpAndWs } from 'devframe/internal' +import { createH3DevframeHost } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { createEventEmitter } from 'devframe/utils/events' import { getPort } from 'get-port-please' import { H3 } from 'h3' +import { serveTestContext } from '../../../tests/helpers/serve-test-context' import { createTerminalsDevframe } from '../src/index' import { getTerminalManager } from '../src/node/index' @@ -92,7 +93,7 @@ export async function startTerminalsServer( (ctx as { terminals?: FakeHubTerminals }).terminals = hub await definition.setup(ctx) - const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + const server = await serveTestContext({ context: ctx, host, port, app, auth: false }) // Tear down spawned terminal processes (PTYs / piped children) alongside // the HTTP+WS server so tests don't leak `node`/shell processes. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d879572..a225685e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,9 +409,15 @@ importers: bumpp: specifier: catalog:tooling version: 12.1.1 + crossws: + specifier: ^0.4.10 + version: 0.4.10(srvx@0.12.4) eslint: specifier: catalog:tooling version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + h3: + specifier: catalog:deps + version: 2.0.1-rc.26(crossws@0.4.10(srvx@0.12.4)) knip: specifier: catalog:tooling version: 6.31.0 @@ -488,9 +494,6 @@ importers: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 - get-port-please: - specifier: catalog:deps - version: 3.2.0 pathe: specifier: catalog:deps version: 2.0.3 diff --git a/storybook/README.md b/storybook/README.md index e3265932..6c70e10e 100644 --- a/storybook/README.md +++ b/storybook/README.md @@ -6,10 +6,10 @@ the live terminals plugin running as a real integration. ## How it works -The whole host is one Vite plugin (`src/hub.ts`): it creates a hub context, -implements the framework-neutral `DevframeHost`, registers a launcher dock (and -a bound command) per plugin Storybook, mounts the terminals plugin via -`mountDevframe`, and starts a side-car RPC/WS server. +The whole host is one Vite plugin (`src/hub.ts`): one `initHub()` call mounts +the terminals plugin (via the `devframes` list) and, in its `configure(ctx)` +step, registers a launcher dock (and a bound command) per plugin Storybook — +all behind the hub's connect middleware on a side-car RPC/WS server. Each Storybook dock is a `type: 'launcher'` tile with a **Start** button — the lazy trigger. The button binds a `ctx.commands` command (`storybook:launch:`), diff --git a/storybook/src/hub.ts b/storybook/src/hub.ts index ab6d76b9..168e8829 100644 --- a/storybook/src/hub.ts +++ b/storybook/src/hub.ts @@ -1,6 +1,6 @@ +import type { HubInstance } from '@devframes/hub/initiate' import type { DevframeHubContext } from '@devframes/hub/node' import type { DevframeChildProcessTerminalSession, DevframeViewLauncher } from '@devframes/hub/types' -import type { DevframeHost } from 'devframe/types' import type { Buffer } from 'node:buffer' import type { Plugin, PreviewServer, ResolvedConfig, ViteDevServer } from 'vite' import { existsSync } from 'node:fs' @@ -8,10 +8,8 @@ import { createRequire } from 'node:module' import { homedir } from 'node:os' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { initHub } from '@devframes/hub/initiate' import terminalsDevframe from '@devframes/plugin-terminals' -import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { startHttpAndWs } from 'devframe/internal' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { dirname, join } from 'pathe' @@ -100,7 +98,7 @@ export interface StorybookHubOptions { export function storybookHub(options: StorybookHubOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined - let started: { close: () => Promise } | undefined + let hub: HubInstance | undefined const devServers = new Map, session: DevframeChildProcessTerminalSession }>() function killDevServers(): void { @@ -185,34 +183,33 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { } async function startHub(server: ViteDevServer | PreviewServer, mode: 'dev' | 'build'): Promise { - await started?.close().catch(() => {}) - started = undefined + await hub?.close().catch(() => {}) + hub = undefined killDevServers() const cwd = viteConfig?.root ?? process.cwd() - // Prefer 9787 but keep booting when it's taken (e.g. a lingering previous - // instance) — walk the range, then fall back to a random free port. The - // client discovers whatever was chosen via `__connection.json`. - const port = options.port ?? await getPort({ port: 9787, portRange: [9787, 9887] }) - - const serveConnectionMeta = (metaBase: string): void => { - server.middlewares.use(`${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}`, (_req, res) => { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port })) - }) + + // In build mode, serve each pre-built Storybook on the Vite server itself + // — outside the hub base, so a launcher iframe resolves it on this origin. + if (mode === 'build') { + for (const meta of STORYBOOKS) { + if (existsSync(storybookStaticDir(meta.id))) + server.middlewares.use(`/__sb-${meta.id}/`, serveStaticNodeMiddleware(storybookStaticDir(meta.id))) + } } - const host: DevframeHost = { - mountStatic(mountBase, distDir) { - server.middlewares.use(mountBase, serveStaticNodeMiddleware(distDir)) - }, - mountConnectionMeta(metaBase) { - serveConnectionMeta(metaBase) - }, - resolveOrigin() { - const resolved = server.resolvedUrls?.local?.[0] - return resolved ? new URL(resolved).origin : 'http://localhost:5173' - }, + hub = initHub({ + base, + cwd, + // Bind dual-stack (`::` accepts IPv6 + IPv4-mapped) so the side-car is + // dialable via `::1`, `127.0.0.1`, and from outside the machine — the + // default `localhost` bind resolves to `::1` only on some hosts, which + // strands IPv4 clients and remote browsers. + host: '::', + auth: false, + // Prefer 9787 but fall back to a free port when taken; the client + // discovers whatever was chosen via `__connection.json`. + ws: options.port != null ? { port: options.port } : { sidecar: true }, getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') @@ -220,117 +217,97 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { return join(cwd, 'node_modules/.devframe-storybook') return join(homedir(), '.devframe-storybook') }, - } - - const context = await createHubContext({ - cwd, - workspaceRoot: cwd, - mode, - host, - }) - - // In build mode, serve each pre-built Storybook so its dock iframe resolves - // on this single origin. - if (mode === 'build') { - for (const meta of STORYBOOKS) { - if (existsSync(storybookStaticDir(meta.id))) - context.views.hostStatic(`/__sb-${meta.id}/`, storybookStaticDir(meta.id)) - } - } - - // Live launcher handles, so the launch command can patch each tile's - // status/digest/terminalSessionId as the process boots. - const launchers = new Map) => void }>() - - /** The full launcher payload for a tile (patched wholesale — `update` shallow-merges). */ - const launcherState = ( - meta: StorybookMeta, - patch: Partial, - ): DevframeViewLauncher['launcher'] => ({ - icon: meta.icon, - title: `${meta.title} Storybook`, - description: mode === 'build' - ? `Open the pre-built ${meta.title} Storybook` - : `Start the ${meta.title} plugin's Storybook dev server`, - command: launchCommandFor(meta.id), - buttonStart: mode === 'build' ? 'Open Storybook' : 'Start Storybook', - buttonLoading: 'Starting…', - status: 'idle', - ...patch, - }) - - /** - * The launch handler bound to each launcher's command. Spawns the dev - * server through `ctx.terminals` (in dev), patches the tile as it boots, - * and returns the resolved URL for the client to iframe in place. - */ - const launchStorybook = async (meta: StorybookMeta): Promise => { - const handle = launchers.get(meta.id) - const patch = (p: Partial): void => - handle?.update({ launcher: launcherState(meta, p) }) - - if (mode === 'build') { - if (!existsSync(storybookStaticDir(meta.id))) { - const error = 'Storybook not built. Run `pnpm storybook:build` first.' - patch({ status: 'error', error }) - return { ok: false, error } + // The live terminals plugin — a real integration docked alongside the + // Storybooks, grouped separately so its "Terminals" reads apart from the + // "Terminals" Storybook. It also mirrors the hub's `ctx.terminals` + // sessions, so the spawned `storybook dev` processes appear inside it. + devframes: [{ devframe: terminalsDevframe, dock: { category: 'Plugins' } }], + configure(context) { + // Live launcher handles, so the launch command can patch each tile's + // status/digest/terminalSessionId as the process boots. + const launchers = new Map) => void }>() + + /** The full launcher payload for a tile (patched wholesale — `update` shallow-merges). */ + const launcherState = ( + meta: StorybookMeta, + patch: Partial, + ): DevframeViewLauncher['launcher'] => ({ + icon: meta.icon, + title: `${meta.title} Storybook`, + description: mode === 'build' + ? `Open the pre-built ${meta.title} Storybook` + : `Start the ${meta.title} plugin's Storybook dev server`, + command: launchCommandFor(meta.id), + buttonStart: mode === 'build' ? 'Open Storybook' : 'Start Storybook', + buttonLoading: 'Starting…', + status: 'idle', + ...patch, + }) + + /** + * The launch handler bound to each launcher's command. Spawns the dev + * server through `ctx.terminals` (in dev), patches the tile as it + * boots, and returns the resolved URL for the client to iframe in place. + */ + const launchStorybook = async (meta: StorybookMeta): Promise => { + const handle = launchers.get(meta.id) + const patch = (p: Partial): void => + handle?.update({ launcher: launcherState(meta, p) }) + + if (mode === 'build') { + if (!existsSync(storybookStaticDir(meta.id))) { + const error = 'Storybook not built. Run `pnpm storybook:build` first.' + patch({ status: 'error', error }) + return { ok: false, error } + } + patch({ status: 'success' }) + return { ok: true, kind: 'path', url: `/__sb-${meta.id}/` } + } + + patch({ status: 'loading', digest: 'Starting Storybook dev server…' }) + try { + const port = await ensureDevServer(context, meta, line => + patch({ status: 'loading', terminalSessionId: sessionIdFor(meta.id), digest: line })) + patch({ status: 'success', terminalSessionId: sessionIdFor(meta.id), digest: `Ready on port ${port}` }) + return { ok: true, kind: 'port', port } + } + catch (error) { + const message = (error as Error).message + patch({ status: 'error', terminalSessionId: sessionIdFor(meta.id), error: message }) + return { ok: false, error: message } + } } - patch({ status: 'success' }) - return { ok: true, kind: 'path', url: `/__sb-${meta.id}/` } - } - - patch({ status: 'loading', digest: 'Starting Storybook dev server…' }) - try { - const port = await ensureDevServer(context, meta, line => - patch({ status: 'loading', terminalSessionId: sessionIdFor(meta.id), digest: line })) - patch({ status: 'success', terminalSessionId: sessionIdFor(meta.id), digest: `Ready on port ${port}` }) - return { ok: true, kind: 'port', port } - } - catch (error) { - const message = (error as Error).message - patch({ status: 'error', terminalSessionId: sessionIdFor(meta.id), error: message }) - return { ok: false, error: message } - } - } - // One launcher dock per plugin Storybook, each bound to a command. A viewer - // dispatches the command over `hub:commands:execute` (the serializable - // path — the handler is stripped when the entry crosses into shared state), - // and reads back the {@link EnsureStorybookResult} to iframe the result. - for (const meta of STORYBOOKS) { - context.commands.register({ - id: launchCommandFor(meta.id), - title: `${mode === 'build' ? 'Open' : 'Start'} ${meta.title} Storybook`, - icon: meta.icon, - category: 'Storybooks', - handler: () => launchStorybook(meta), - }) - launchers.set(meta.id, context.docks.register({ - id: dockIdFor(meta.id), - title: meta.title, - icon: meta.icon, - category: 'Storybooks', - type: 'launcher', - launcher: launcherState(meta, { status: 'idle' }), - })) - } - - // The live terminals plugin — a real integration docked alongside the - // Storybooks, grouped separately so its "Terminals" reads apart from the - // "Terminals" Storybook. It also mirrors the hub's `ctx.terminals` - // sessions, so the spawned `storybook dev` processes appear inside it. - await mountDevframe(context, terminalsDevframe, { dock: { category: 'Plugins' } }) + // One launcher dock per plugin Storybook, each bound to a command. A + // viewer dispatches the command over `hub:commands:execute` (the + // serializable path — the handler is stripped when the entry crosses + // into shared state), and reads back the {@link EnsureStorybookResult} + // to iframe the result. + for (const meta of STORYBOOKS) { + context.commands.register({ + id: launchCommandFor(meta.id), + title: `${mode === 'build' ? 'Open' : 'Start'} ${meta.title} Storybook`, + icon: meta.icon, + category: 'Storybooks', + handler: () => launchStorybook(meta), + }) + launchers.set(meta.id, context.docks.register({ + id: dockIdFor(meta.id), + title: meta.title, + icon: meta.icon, + category: 'Storybooks', + type: 'launcher', + launcher: launcherState(meta, { status: 'idle' }), + })) + } + }, + }) - // Bind dual-stack (`::` accepts IPv6 + IPv4-mapped) so the side-car is - // dialable via `::1`, `127.0.0.1`, and from outside the machine — the - // default `localhost` bind resolves to `::1` only on some hosts, which - // strands IPv4 clients and remote browsers. - started = await startHttpAndWs({ context, port, host: '::', auth: false }) - serveConnectionMeta(base) + server.middlewares.use(hub.nodeMiddleware) server.httpServer?.once('close', () => { killDevServers() - void started?.close().catch(() => {}) + void hub?.close().catch(() => {}) }) } @@ -353,8 +330,8 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { async closeBundle() { killDevServers() - await started?.close().catch(() => {}) - started = undefined + await hub?.close().catch(() => {}) + hub = undefined }, } } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 8f0ad7f6..20672820 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -131,6 +131,7 @@ export interface DevframeHubContext extends DevframeNodeContext { terminals: DevframeTerminalsHost; messages: DevframeMessagesHost; commands: DevframeCommandsHost; + install: (_: DevframeDefinition, _?: InstallDevframeOptions) => Promise; } export interface DevframeMessageActivateAction { id: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index ddf38276..4f33eefa 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -13,7 +13,7 @@ export interface DevframeHubUi { } export interface HubDevframeEntry { devframe: DevframeDefinition; - dock?: MountDevframeOptions['dock']; + dock?: InstallDevframeOptions['dock']; } export interface HubInstance { base: string; @@ -41,6 +41,7 @@ export interface InitHubOptions { auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; origin?: string | (() => string); + register?: boolean | Partial; cwd?: string; getStorageDir?: (_: DevframeStorageScope) => string; allowedOrigins?: readonly string[] | WsOriginRegistry | false; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 86346a7a..33011b13 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -272,6 +272,5 @@ export declare const hubTerminalsWrite: { export { createHubContext } export { CreateHubContextOptions } export { DevframeHubContext } -export { mountDevframe } -export { MountDevframeOptions } +export { InstallDevframeOptions } // #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 index 2bf9b471..e8bd00c5 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -23,5 +23,4 @@ export { hubTerminalsResize } export { hubTerminalsRestart } export { hubTerminalsTerminate } export { hubTerminalsWrite } -export { mountDevframe } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts index 9e526cbb..f9bcfbda 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts @@ -26,6 +26,7 @@ export interface InitDevframeOptions { auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; origin?: string | (() => string); + register?: boolean | Partial; flags?: Record; allowedOrigins?: readonly string[] | WsOriginRegistry | false; app?: H3; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index fac4cc31..310a067e 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -9,73 +9,10 @@ export interface CreateH3DevframeHostOptions { appName: string; workspaceRoot?: string; } -export interface CreateInstanceShellOptions { - base: string; - app?: H3; - origin?: string | (() => string); - auth?: boolean | DevframeAuthHandler; - server?: Server; - ws?: DevframeWsOptions; - host?: string; - allowedOrigins?: readonly string[] | WsOriginRegistry | false; - destroyUnmatchedUpgrades?: boolean; - onPeerConnect?: (_: Peer, _: DevframeNodeRpcSession) => void; - onPeerDisconnect?: (_: Peer, _: DevframeNodeRpcSessionMeta) => void; - absoluteWsPath?: boolean; - resolveSidecarPort?: (_: string) => Promise; - init: (_: InstanceShellApi) => Promise>; - mount?: (_: TContext, _: ConnectionMeta, _: InstanceShellApi) => void | Promise; - onMetaUnavailable: () => never; -} -export interface DevframeInstanceRecord { - pid: number; - port: number; - origin: string; - basePath: string; - id: string; - name?: string; - rootDir: string; - mcp: { - path: string; - } | null; - startedAt: number; -} -export interface DevframeInstanceRegistration { - readonly file: string; - unregister: () => void; -} -export interface InstanceShell { - base: string; - handler: (_: Request) => Promise; - nodeMiddleware: (_: IncomingMessage, _: ServerResponse, _?: (_?: unknown) => void) => void; - ready: Promise; - context: Promise; - connectionMeta: () => ConnectionMeta; - handleUpgrade: (_: IncomingMessage, _: Duplex, _: Buffer) => void; - attach: (_: Server) => () => void; - close: () => Promise; - internals: InstanceShellInternals; -} -export interface InstanceShellApi { - base: string; - app: H3; - origin: () => string | undefined; - connectionMeta: () => ConnectionMeta | undefined; -} -export interface InstanceShellInit { - context: TContext; - mcp?: ConnectionMeta['mcp']; - dispose?: () => Promise; -} -export interface InstanceShellInternals { - readonly started?: StartedServer; - readonly authHandler?: DevframeAuthHandler; -} // #endregion // #region Types export type AgentArgsFallback = 'wrap' | 'drop'; -export type InstanceWsTier = 'sidecar' | 'server' | 'external' | 'unbound'; // #endregion // #region Classes @@ -109,26 +46,26 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { // #region Functions export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[]; export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; -export declare function createInstanceShell(_: CreateInstanceShellOptions): InstanceShell; -export declare function listLiveDevframeInstances(_?: { - instancesDir?: string; - timeoutMs?: number; -}): Promise<{ - live: DevframeInstanceRecord[]; - pruned: DevframeInstanceRecord[]; -}>; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; -export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { - instancesDir?: string; -}): DevframeInstanceRegistration; -export declare function samePath(_: string, _: string): boolean; // #endregion // #region Other export { ContextRpcServer } export { createContextRpcServer } export { CreateContextRpcServerOptions } +export { createInstanceShell } +export { CreateInstanceShellOptions } +export { DevframeInstanceRecord } +export { DevframeInstanceRegistration } +export { InstanceRegisterConfig } +export { InstanceShell } +export { InstanceShellApi } +export { InstanceShellInit } +export { InstanceShellInternals } +export { InstanceWsTier } +export { listLiveDevframeInstances } +export { registerDevframeInstance } +export { resolveInstanceRegister } +export { samePath } export { StartedServer } -export { startHttpAndWs } -export { StartHttpAndWsOptions } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 54c0adea..b68df86d 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -10,6 +10,6 @@ export { DevframeAgentHost } export { listLiveDevframeInstances } export { normalizeHttpServerUrl } export { registerDevframeInstance } +export { resolveInstanceRegister } export { samePath } -export { startHttpAndWs } // #endregion \ No newline at end of file diff --git a/tests/helpers/serve-test-context.ts b/tests/helpers/serve-test-context.ts new file mode 100644 index 00000000..ae2e5071 --- /dev/null +++ b/tests/helpers/serve-test-context.ts @@ -0,0 +1,105 @@ +import type { Peer } from 'crossws' +import type { StartedServer } from 'devframe/internal' +import type { DevframeAuthHandler } from 'devframe/node/auth' +import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession } from 'devframe/types' +import type { H3 } from 'h3' +import { createServer } from 'node:http' +import { isIP } from 'node:net' +import { createContextRpcServer } from 'devframe/internal' +import { getInternalContext } from 'devframe/node/hub-internals' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { H3 as H3App, toNodeHandler } from 'h3' + +/** Loopback / wildcard binds aren't dialable as-is — advertise `localhost`. */ +function formatHostForUrl(host: string): string { + const dialable = ['0.0.0.0', '127.0.0.1', '::', ''].includes(host) ? 'localhost' : host + return isIP(dialable) === 6 ? `[${dialable}]` : dialable +} + +export interface ServeTestContextOptions { + /** The devframe node context whose registered RPC functions to serve. */ + context: DevframeNodeContext + /** Bind host. Default: `localhost`. */ + host?: string + /** Listening port (`0` binds an ephemeral one). */ + port: number + /** h3 app to serve (SPA + connection meta). A fresh empty one is used when omitted. */ + app?: H3 + /** Auth intent, same contract as the shell binding: `false` auto-trusts. */ + auth?: boolean | DevframeAuthHandler + /** Called once per new WS connection, right after its session is created. */ + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void +} + +/** + * Stand up a real HTTP + WebSocket RPC server for a hand-built devframe + * context — the in-process test counterpart to the binding `initDevframe` / + * `initHub` perform internally. Test harnesses that need a live origin, + * direct `ctx` access, an injected fake host, or a custom `cwd` build their + * context by hand and serve it through this helper; production code reaches + * for `initDevframe` / `initHub` / `createDevServer` instead. + */ +export async function serveTestContext(options: ServeTestContextOptions): Promise { + const { context, port } = options + const bindHost = options.host ?? 'localhost' + const app = options.app ?? new H3App() + const httpServer = createServer(toNodeHandler(app)) + const rpcHost = context.rpc as unknown as { + definitions: Map + } + + const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ + context, + auth: options.auth, + onPeerConnect: options.onPeerConnect, + }) + + const { ws, close: closeWs } = attachWsRpcTransport(rpcGroup, { + server: httpServer, + destroyUnmatched: true, + onConnected, + onDisconnected, + }) + + await new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + httpServer.once('error', onError) + httpServer.listen(port, bindHost, () => { + httpServer.removeListener('error', onError) + resolve() + }) + }) + + const address = httpServer.address() + const resolvedPort = typeof address === 'object' && address ? address.port : port + const origin = `http://${formatHostForUrl(bindHost)}:${resolvedPort}` + + // Publish the dialable socket URL on the context, mirroring the shell's own + // binding, so surfaces that hand out a complete endpoint work in tests too. + const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}` + getInternalContext(context).wsEndpoint = { url: wsUrl } + + function connectionMeta(): ConnectionMeta { + const jsonSerializableMethods: string[] = [] + for (const def of rpcHost.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + return { backend: 'websocket', websocket: {}, jsonSerializableMethods } + } + + return { + origin, + port: resolvedPort, + app, + ws, + rpcGroup, + connectionMeta, + async close() { + await closeWs() + await new Promise(r => httpServer.close(() => r())) + if (getInternalContext(context).wsEndpoint?.url === wsUrl) + getInternalContext(context).wsEndpoint = undefined + }, + } +} From 58d7c12ff72b7cad54f9b374d1a3d0651ca43500 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 12 Aug 2026 07:55:07 +0000 Subject: [PATCH 2/2] fix: green up hub-hono-minimal typecheck and rewrite the 0.9 migration guide The `unit-test / lint` CI job runs `pnpm typecheck`; `hub-hono-minimal` had a latent, cache-masked failure (unresolved `Bun` global in the Bun entry and a hono `ServerType` vs `node:http` `Server` mismatch). Type the Bun entry's slice locally instead of pulling `@types/bun`, and narrow the hono server cast. Revise `docs/guide/migration-0.9.md` end to end so it describes the final 0.9 surface: `startHttpAndWs` and `mountDevframe` are now documented as removed (in favor of `createDevServer`/`initDevframe`/`initHub` and `ctx.install`), the new opt-in `register` flag is covered, and the internals-move table reflects `StartedServer` (not `startHttpAndWs`) relocating to `devframe/internal`. --- docs/guide/migration-0.9.md | 65 ++++++++++++++++++++++++- examples/hub-hono-minimal/src/bun.ts | 12 +++++ examples/hub-hono-minimal/src/server.ts | 5 +- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/docs/guide/migration-0.9.md b/docs/guide/migration-0.9.md index 271e6683..322a469a 100644 --- a/docs/guide/migration-0.9.md +++ b/docs/guide/migration-0.9.md @@ -145,14 +145,63 @@ The low-level primitives shared between `devframe` and its first-party integrati | Moved | From | To | |---|---|---| | `createH3DevframeHost` (+ `CreateH3DevframeHostOptions`) | `devframe/node` | `devframe/internal` | -| `startHttpAndWs` (+ `StartedServer`, `StartHttpAndWsOptions`) | `devframe/node` | `devframe/internal` | | `createContextRpcServer` (+ `ContextRpcServer`, `CreateContextRpcServerOptions`) | `devframe/node` | `devframe/internal` | +| `StartedServer` (the `createDevServer` return handle) | `devframe/node` | `devframe/internal` | | `DevframeAgentHost` (class) | `devframe/node` | `devframe/internal` | | `coerceAgentPositionalArgs` (+ `AgentArgsFallback`) | `devframe/node` | `devframe/internal` | | `registerDevframeInstance` / `listLiveDevframeInstances` (+ `DevframeInstanceRecord`, `DevframeInstanceRegistration`) | `devframe/node` | `devframe/internal` | | `normalizeHttpServerUrl` | `devframe/node` | `devframe/internal` | -A host that stands up its own server composes from `devframe/internal` — `createH3DevframeHost` for the node `DevframeHost`, `createContextRpcServer` + `devframe/rpc/transports/*` to bind a transport — plus `devframe/node`'s `createHostContext` and `devframe/node/hub-internals`. This is the path `@devframes/hub`'s `initHub` takes (in later releases `startHttpAndWs` was removed in favor of `initDevframe` / `initHub`). A custom host advertises itself with `registerDevframeInstance`, and a devtool enumerates running instances with `listLiveDevframeInstances`. Application code should prefer the adapters and `devframe/initiate`. +A host that stands up its own server composes from `devframe/internal` — `createH3DevframeHost` for the node `DevframeHost`, `createContextRpcServer` + `devframe/rpc/transports/*` to bind a transport — plus `devframe/node`'s `createHostContext` and `devframe/node/hub-internals`. This is the path `@devframes/hub`'s `initHub` takes. A custom host advertises itself with `registerDevframeInstance` (or the new `register` flag, below), and a devtool enumerates running instances with `listLiveDevframeInstances`. Application code should prefer the adapters and `devframe/initiate`. + +## `startHttpAndWs` is removed + +The low-level "listen on a port + attach the WS transport" primitive is gone. `createDevServer`, `initDevframe`, and `initHub` own that binding internally now, resolving the transport from their own `server` / `ws` options — so the common paths never touch it. `StartHttpAndWsOptions` is removed with it; `StartedServer` stays (it is still `createDevServer`'s return handle, re-exported from `devframe/internal`). + +| 0.8.x | 0.9 | +|---|---| +| `startHttpAndWs({ context, port, ... })` for a standalone tool | `createDevServer(def, { port, ... })` | +| `startHttpAndWs(...)` inside a framework host | `initDevframe(def, { base, ... })` / `initHub({ base, ... })` | + +A host that genuinely binds its own transport — a bare RPC socket, or a server it wires itself — composes the two public primitives `startHttpAndWs` used underneath: `createContextRpcServer` (`devframe/internal`) for the session/auth wiring, and a transport from `devframe/rpc/transports/*`. + +```ts +// 0.9 — bind the RPC socket onto a server you own +import { createServer } from 'node:http' +import { createContextRpcServer } from 'devframe/internal' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' + +const httpServer = createServer() +const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ context, auth: false }) +attachWsRpcTransport(rpcGroup, { server: httpServer, onConnected, onDisconnected }) +httpServer.listen(port) +``` + +## `@devframes/hub`'s `mountDevframe` is removed — use `ctx.install` + +The free `mountDevframe(ctx, def, options)` function is replaced by an `install` method on the hub context. `MountDevframeOptions` is renamed `InstallDevframeOptions`. `initHub`'s declarative `devframes` list runs the same install path under the hood, so most hosts never call it directly. + +| 0.8.x | 0.9 | +|---|---| +| `import { mountDevframe } from '@devframes/hub/node'` | removed — call `ctx.install` | +| `await mountDevframe(ctx, def, opts)` | `await ctx.install(def, opts)` | +| `MountDevframeOptions` | `InstallDevframeOptions` (from `@devframes/hub/node`) | + +```ts +// 0.8.x +import { createHubContext, mountDevframe } from '@devframes/hub/node' + +const ctx = await createHubContext({ host, cwd, mode: 'dev' }) +await mountDevframe(ctx, myDevframe) +``` + +```ts +// 0.9 +import { createHubContext } from '@devframes/hub/node' + +const ctx = await createHubContext({ host, cwd, mode: 'dev' }) +await ctx.install(myDevframe) +``` ## `@devframes/hub` category order lives only on `/constants` @@ -185,6 +234,16 @@ const detach = hub.attach(serve({ fetch: app.fetch, port: 3000 })) Calling `attach` / `handleUpgrade` on an instance that already owns a transport reports [`DF0055`](/errors/DF0055), and on the advertise-only `ws.url` tier [`DF0056`](/errors/DF0056). +## `initDevframe` / `initHub` can register themselves + +An in-process host used to call `registerDevframeInstance` by hand to appear in the global instance registry (`~/.devframe/instances/`, read by `devframe connect` and the inspect plugin's Instances tab). Both factories now take an opt-in `register` flag that does it for them: a dynamic import that writes the record once the public origin resolves and removes it on `close()`. `createDevServer` registers this way automatically. + +| 0.8.x | 0.9 | +|---|---| +| manual `registerDevframeInstance({ pid, port, origin, … })` + `unregister()` on every close path | `initHub({ base, register: true })` / `initDevframe(def, { base, register: true })` | + +Pass an object to override individual record fields — `register: { id, name, rootDir }`. `registerDevframeInstance` / `listLiveDevframeInstances` remain on `devframe/internal` for hosts that drive the registry directly. + ## The `key` option is removed; memoize on `globalThis` `initDevframe` and `initHub` no longer memoize instances under a `key` (and the `DF0053` / `DF8001` replacement diagnostics are gone with it). A host that re-evaluates its modules in dev owns the memo, which makes the lifecycle visible at the call site: @@ -227,3 +286,5 @@ Bun.serve({ ``` `examples/hub-hono-minimal` ships this wiring in [`src/bun.ts`](https://github.com/devframes/devframe/blob/main/examples/hub-hono-minimal/src/bun.ts), next to the Node entry's `hub.attach(server)`. + + diff --git a/examples/hub-hono-minimal/src/bun.ts b/examples/hub-hono-minimal/src/bun.ts index 5ec64be3..406eecc1 100644 --- a/examples/hub-hono-minimal/src/bun.ts +++ b/examples/hub-hono-minimal/src/bun.ts @@ -3,6 +3,18 @@ import { createContextRpcServer } from 'devframe/internal' import { attachBunWsTransport } from 'devframe/rpc/transports/ws-bun' import { app, hub } from './app' +// `Bun` is a global on the Bun runtime only; this is the Bun entry. Declare the +// slice this file uses so `tsc` (Node types only, no `@types/bun`) can check it +// — typing the `fetch` callback's params here is also what keeps them from +// being implicitly `any`. +declare const Bun: { + serve: (options: { + port: number + fetch: (request: Request, server: unknown) => Response | Promise + websocket: unknown + }) => { readonly port: number, stop: (closeActiveConnections?: boolean) => void } +} + /** * The Bun entry. Bun serves HTTP through the same `app.fetch` as Node, but * WebSockets arrive as fetch upgrades rather than `node:http` `upgrade` diff --git a/examples/hub-hono-minimal/src/server.ts b/examples/hub-hono-minimal/src/server.ts index 34aa8a60..14f8803f 100644 --- a/examples/hub-hono-minimal/src/server.ts +++ b/examples/hub-hono-minimal/src/server.ts @@ -1,3 +1,4 @@ +import type { Server } from 'node:http' import process from 'node:process' import { serve } from '@hono/node-server' import { app, hub } from './app' @@ -10,7 +11,9 @@ import { app, hub } from './app' // for one instead with `ws: { sidecar: true }`. const port = Number(process.env.PORT ?? 5179) -const server = serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }) +// `serve()` returns `ServerType` (its http/http2 union); the default is a +// plain `node:http` server, which is what `hub.attach` routes upgrades on. +const server = serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }) as unknown as Server const detach = hub.attach(server) process.on('SIGINT', () => {