Skip to content

feat!: share one instance shell; make the WebSocket binding explicit - #191

Merged
antfu merged 1 commit into
mainfrom
feat/instance-shell-explicit-transport
Aug 12, 2026
Merged

feat!: share one instance shell; make the WebSocket binding explicit#191
antfu merged 1 commit into
mainfrom
feat/instance-shell-explicit-transport

Conversation

@antfu

@antfu antfu commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Implements the four TODOs left in initDevframe / initHub: they duplicated ~250 lines of the same wiring, memoized instances on a key, carried a Bun tier, and started a side-car WebSocket server nobody asked for.

One shared shell

New internal createInstanceShell (exported from the unstable devframe/internal) owns everything both factories had in common: the mount base and h3 app, the lazily-derived public origin and the auth banner that waits for it, auth resolution, the WebSocket binding, connection meta, the fetch / connect-middleware pair, the upgrade API, and teardown. Each factory now supplies only what makes it itself — its context, its routes, its diagnostics.

packages/hub/src/node/initiate.ts −628 lines, packages/devframe/src/adapters/initiate.ts −544, against +462 for the shell.

The WebSocket binding is the host's call

Nothing binds a port on its own. The local binding resolves:

  1. ws.port — a side-car on that exact port
  2. server — share the host's node:http upgrade at <base>__ws
  3. ws: { sidecar: true }new: a side-car on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild)
  4. none of the above — the socket waits for the host: instance.attach(server) routes a server's upgrade events (returning a detach fn), instance.handleUpgrade(req, socket, head) completes one from a listener you already own

That last tier builds its transport lazily, so an instance nobody attaches binds nothing and loads nothing. ws.url keeps controlling the advertisement only (the tunnel pattern). Handing upgrades to an instance that already owns its socket reports the new DF0055, or DF0056 on the advertise-only ws.url tier.

Breaking changes

All covered by new sections in docs/guide/migration-0.9.md.

Removed Replacement
implicit side-car ws: { sidecar: true }
key (+ DF0053 / DF8001) a globalThis memo at the call site; createDevframeNextHandler keeps its key and memoizes internally, so Next hosts need no change
instance.websocket, handler's 2nd arg handler is exactly (request: Request) => Promise<Response>; a Bun host binds the transport itself with createContextRpcServer + attachBunWsTransport (devframe/rpc/transports/ws-bun stays published)

Drive-by fix

resolveDevframesInput had two type errors — Array.isArray doesn't narrow the readonly-array member out of the false branch — and a latent crash: a lazy entry resolving to [null] reached normalizeDevframeEntry(null). Empty slots are filtered now, so conditional devframes lists work.

Consumers

@devframes/next owns its memo and asks for a side-car explicitly; viteDevBridge asks for one when middleware-mode Vite has no server to share; all seven hub examples migrate to the globalThis recipe and explicit transport wiring. examples/hub-hono-minimal splits into src/server.ts (Node, hub.attach(serve(...))) and src/bun.ts (Bun's own transport over the hub context) — crossws' Node adapter refuses to run under Bun, so node-compat can't serve that socket.

Verification

pnpm lint, pnpm knip, pnpm test (1081 passed; API snapshots regenerated — the diff is exactly the surface change above), pnpm build and pnpm docs:build all pass.

Booted for real: bun scripts/smoke-bun.ts (Bun fetch-upgrade RPC round-trip), the Hono Node entry (RPC over the attached upgrade), hub-vite-minimal (shared server → {path: '/__devframes/__ws'}), hub-nitro-minimal (side-car → 9777).

pnpm typecheck has one pre-existing, unrelated failure left alone: packages/hub-ui/scripts/build-css.ts on duplicated magic-string (0.30.21 vs 1.0.0). It reproduces with these changes stashed; fixing it is a dependency-dedupe decision.

🤖 Generated with Claude Code

`initDevframe` and `initHub` duplicated ~250 lines of the same wiring and
each carried machinery that belongs to their hosts. Both are now assembled
from one internal shell, and neither binds a port unless asked to.

- Add `createInstanceShell` (`devframe/internal`), owning the mount base and
  h3 app, the lazily-derived origin and the auth banner that waits for it,
  auth resolution, the WebSocket binding, connection meta, the fetch /
  connect-middleware pair, the upgrade API and teardown. Each factory now
  supplies only its own context, routes and diagnostics.
- Resolve the WS binding as `ws.port` > `server` > `ws.sidecar` (new) > the
  host's own upgrades, via `instance.attach(server)` / `handleUpgrade()`.
  The default tier builds its transport lazily, so an instance nobody
  attaches binds nothing and loads nothing. `DF0055` / `DF0056` report an
  upgrade hand-off to an instance that already owns its socket.
- Drop the implicit side-car: hosts whose handlers never see upgrades
  (Next.js, Nitro, Rsbuild) now ask for one with `ws: { sidecar: true }`.
- Drop the `key` memoization (and `DF0053` / `DF8001`) — a host that
  re-evaluates its modules owns the `globalThis` memo, which keeps the
  lifecycle visible at the call site. `createDevframeNextHandler` keeps its
  `key` option and memoizes internally, so Next hosts need no change.
- Drop the Bun tier: `instance.websocket` and `handler`'s second argument
  are gone, and `handler` is exactly `(request: Request) => Promise<Response>`.
  A Bun host binds the transport itself with `createContextRpcServer` +
  `attachBunWsTransport`, as `examples/hub-hono-minimal/src/bun.ts` now shows
  (`scripts/smoke-bun.ts` still covers it end to end).
- Fix `resolveDevframesInput`: `Array.isArray` left the readonly-array member
  in the false branch (a type error), and an entry resolving to `[null]`
  reached `normalizeDevframeEntry(null)`. Empty slots are filtered, so
  conditional `devframes` lists work.

Also: `attachWsRpcTransport` gains `unbound`, `handleUpgrade` and `attach`;
`DevframeWsOptions` gains `sidecar`; the migration guide covers every step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 06:01
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

Name Link
🔨 Latest commit 3811512
🔍 Latest deploy log https://app.netlify.com/projects/devfra/deploys/6a7c0c4c6ce4070008223323
😎 Deploy Preview https://deploy-preview-191--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@antfu
antfu merged commit 3dc6b3e into main Aug 12, 2026
12 of 13 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the Node “initiate” adapters to share a single internal instance shell and makes WebSocket transport binding explicitly host-driven (with an opt-in sidecar), updating public types, tests, examples, and migration docs to match the new transport tiers and breaking API surface.

Changes:

  • Introduces createInstanceShell() (unstable devframe/internal) to centralize shared init wiring for initDevframe and initHub (origin/auth/meta/ws binding/teardown).
  • Changes the WebSocket binding contract to be explicit: adds ws.sidecar, adds attach()/handleUpgrade(), removes implicit sidecar behavior, removes key, and removes the Bun-specific handler second arg + instance.websocket.
  • Migrates downstream consumers, examples, docs, and API snapshots; expands test coverage for the new binding tiers and refusal diagnostics (DF0055/DF0056).

Reviewed changes

Copilot reviewed 43 out of 48 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/snapshots/tsnapi/devframe/internal.snapshot.js Updates internal API snapshot exports for the new shell utilities.
tests/snapshots/tsnapi/devframe/internal.snapshot.d.ts Updates internal type surface snapshot for createInstanceShell, samePath, and related types.
tests/snapshots/tsnapi/devframe/initiate.snapshot.d.ts Updates initDevframe public API snapshot (handler signature, attach/handleUpgrade, removal of websocket/key).
tests/snapshots/tsnapi/devframe/index.snapshot.d.ts Updates snapshot to include DevframeWsOptions.sidecar.
tests/snapshots/tsnapi/@devframes/hub/initiate.snapshot.d.ts Updates hub initiate snapshot (handler signature, attach/handleUpgrade, DevframesInput, removal of key/websocket).
scripts/smoke-bun.ts Updates Bun smoke test to use the new Bun wiring (startBunServer) and new handler shape.
packages/next/src/handler.ts Switches Next helper to memoize handler on globalThis and explicitly request a WS sidecar.
packages/hub/src/node/mount-devframe.ts Adjusts hub dock registration for mounted devframes (icon handling changed).
packages/hub/src/node/initiate.ts Refactors hub init to use createInstanceShell, adds flexible devframes input resolution, and updates WS binding behavior/docs.
packages/hub/src/node/diagnostics.ts Removes DF8001 (key memoization replacement diagnostic) along with key removal.
packages/hub/src/node/tests/initiate.test.ts Updates tests for explicit WS binding tiers (unbound + attach; ws.sidecar).
packages/devframe/src/types/devframe.ts Documents and adds DevframeWsOptions.sidecar.
packages/devframe/src/rpc/transports/ws-server.ts Adds “unbound” mode plus handleUpgrade/attach APIs and attachment tracking for deterministic teardown.
packages/devframe/src/node/instance-shell.ts Adds new shared instance shell implementing binding tiers and host-driven upgrades.
packages/devframe/src/node/diagnostics.ts Removes DF0053 and adds DF0055/DF0056 for upgrade handoff misuse tiers.
packages/devframe/src/internal/index.ts Exposes createInstanceShell/samePath and related types via devframe/internal.
packages/devframe/src/helpers/vite.ts Updates viteDevBridge to explicitly request ws.sidecar when Vite has no node:http server to share.
packages/devframe/src/adapters/initiate.ts Refactors initDevframe to use the shared shell; updates public surface and WS binding semantics.
packages/devframe/src/adapters/tests/initiate.test.ts Reworks initiate adapter tests for new default/unbound + sidecar tiers and new DF0055/DF0056 behavior.
examples/hub-vite/src/vite-devframe-hub.ts Updates Vite hub plugin to request explicit sidecar when server isn’t a node:http server.
examples/hub-vite-minimal/vite.config.ts Moves hub init into configureServer, removes key, and adds explicit sidecar fallback when no node:http server.
examples/hub-rsbuild-minimal/rsbuild.config.ts Removes key memo and configures explicit sidecar for Rsbuild (middleware stack can’t provide upgrades).
examples/hub-rsbuild-minimal/README.md Updates README to describe explicit sidecar WS binding.
examples/hub-nitro-minimal/README.md Updates README to describe explicit sidecar WS binding.
examples/hub-nitro-minimal/hub.ts Replaces key memoization with explicit globalThis memo and explicit sidecar.
examples/hub-next/src/client/devframe/next-devframe-hub.ts Removes key option and switches to globalThis memo; explicitly requests sidecar.
examples/hub-next/README.md Updates Next example docs for explicit sidecar binding + memoization wording.
examples/hub-next-minimal/src/client/hub.ts Removes key, uses globalThis memo, and explicitly requests sidecar.
examples/hub-hono-minimal/src/server.ts Adds Node entry that attaches upgrades to the hub (hub.attach(server)).
examples/hub-hono-minimal/src/node.ts Removes old Node entry file in favor of unified server.ts.
examples/hub-hono-minimal/src/bun.ts Adds explicit Bun transport wiring using createContextRpcServer + attachBunWsTransport.
examples/hub-hono-minimal/src/app.ts Updates runtime-agnostic app to use new handler signature and globalThis memo; transport wiring moved to runtime entries.
examples/hub-hono-minimal/README.md Updates README to describe Node attach vs Bun fetch-upgrade wiring.
examples/hub-hono-minimal/package.json Updates dev script to use src/server.ts.
docs/helpers/next.md Updates Next helper docs (memoization and corrected import path for startHttpAndWs).
docs/guide/migration-0.9.md Adds migration sections for explicit WS binding, removal of key, and removal of Bun tier from instances.
docs/guide/hub-initiate.md Updates hub initiate guide for new shared socket tiers and new instance surface (attach/handleUpgrade).
docs/examples/hub-rsbuild-minimal.md Updates docs page for explicit sidecar on Rsbuild.
docs/examples/hub-nitro-minimal.md Updates docs page for explicit sidecar on Nitro.
docs/examples/hub-next.md Updates docs page for explicit sidecar + memoization on Next.
docs/examples/hub-next-minimal.md Updates docs page for explicit sidecar + memoization on Next minimal.
docs/examples/hub-hono-minimal.md Updates docs page for Node attach vs Bun transport binding.
docs/errors/DF8001.md Removes obsolete DF8001 doc (key memoization replaced).
docs/errors/DF0056.md Adds new DF0056 doc for external advertised WS tier refusing upgrade handoff.
docs/errors/DF0055.md Adds new DF0055 doc for refusing upgrade handoff when instance already owns transport.
docs/errors/DF0053.md Removes obsolete DF0053 doc (key memoization replaced).
docs/adapters/initiate.md Updates initiate adapter docs for new WS binding tiers, new instance surface, and globalThis memo guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 94 to 98
ctx.docks.register({
id,
title: d.name,
icon: d.icon ?? 'ph:plug-duotone',
icon: d.icon,
// Definition-level `dock` defaults sit above the name/icon-derived
Comment on lines +429 to +436
function attach(server: NodeHttpServer): () => void {
assertUnbound()
server.on('upgrade', handleUpgrade)
// Build the transport now rather than on the first upgrade, so the socket
// is live (and its absolute URL published) the moment the host is.
void ensureTransport().then(() => publishWsEndpoint(server)).catch(() => {})
return () => server.off('upgrade', handleUpgrade)
}
@antfu
antfu deleted the feat/instance-shell-explicit-transport branch August 12, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants