Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co

## Conventions

- **Be very strict about the public API surface.** Every exported symbol on a published subpath is a contract users can depend on — additions and changes must be deliberate, not a side effect of where code happens to live. Before exporting anything new, ask whether it needs to be public at all: helpers shared between first-party packages and transports belong on **`devframe/internal`** (explicitly unstable, can change in any minor release), and module-local code should simply not be exported. Barrel files that `export *` make accidental exposure easy — when adding to a star-exported module, check what rides along. The `tsnapi` snapshots under `tests/__snapshots__/tsnapi/` guard the entire surface: review every snapshot diff as an API-design decision, never regenerate it as a chore, and treat a `TSNAPI_ALLOW_BREAKING` update as something that needs the same scrutiny as the breaking change itself.
- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise — no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
- Shared state via `devframe/utils/shared-state`; keep values serializable.
Expand Down
2 changes: 2 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.m
const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url))

export const alias = {
'devframe/rpc/transports/sse-client': r('devframe/src/rpc/transports/sse-client.ts'),
'devframe/rpc/transports/sse-server': r('devframe/src/rpc/transports/sse-server.ts'),
'devframe/rpc/transports/ws-bun': r('devframe/src/rpc/transports/ws-bun.ts'),
'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'),
'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'),
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function guideItems(prefix: string) {
{ text: 'When Clauses', link: `${prefix}/guide/when-clauses` },
{ text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` },
{ text: 'Client', link: `${prefix}/guide/client` },
{ text: 'Transports', link: `${prefix}/guide/transports` },
{ text: 'Security', link: `${prefix}/guide/security` },
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
{ text: 'Hub', link: `${prefix}/guide/hub` },
Expand Down
36 changes: 36 additions & 0 deletions docs/errors/DF0057.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
outline: deep
---

# DF0057: WebSocket Transport Disabled

## Message

> This instance disables its WebSocket transport (`ws: false`), so there is no socket to drive upgrades into.

## Cause

`ws: false` runs the instance without a WebSocket: clients connect over the SSE endpoint instead (`backend: 'sse'`). There is therefore no socket for `attach(server)` / `handleUpgrade(req, socket, head)` to feed — the host wiring that exists solely to route `upgrade` events has nothing to route to.

## Example

```ts
import { initDevframe } from 'devframe/initiate'

const sseOnly = initDevframe(def, {
base: '/__my-tool/',
ws: false,
})
sseOnly.attach(myServer) // ✗ throws DF0057 — there is no socket

// ✓ SSE needs no upgrade wiring; serve the HTTP surface and you're done.
myServer.on('request', (req, res) => sseOnly.nodeMiddleware(req, res))
```

## Fix

Drop the `attach` / `handleUpgrade` wiring — the SSE endpoint rides the instance's ordinary HTTP surface (`handler` / `nodeMiddleware`), so serving requests is all a host needs to do. Remove `ws: false` if the instance should serve a WebSocket after all.

## Source

- [`packages/devframe/src/node/instance-shell.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-shell.ts) — the shared instance shell throws this from `attach` / `handleUpgrade` when the WebSocket tier is `disabled`, for both `initDevframe` and `initHub`.
58 changes: 58 additions & 0 deletions docs/guide/transports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
outline: deep
---

# Transports

Devframe serves live RPC over two interchangeable transports — a WebSocket and an SSE endpoint — so a client connects even where the WebSocket upgrade is unavailable (serverless platforms, buffering reverse proxies, restrictive corporate networks). Both speak the identical birpc wire protocol with the same per-method serialization, auth handshake, origin policy, shared state, and streaming; switching transports changes nothing about how you write or call RPC functions.

## What the server binds

A live instance binds both by default:

- **WebSocket** at `<base>__ws` — the primary transport, one full-duplex socket.
- **SSE** at `<base>__sse` — one method-dispatched route: `GET` opens the server→client event stream, `POST` carries client→server RPC frames. It rides the same HTTP surface that serves `__connection.json`, so wherever discovery works, SSE works — including through the Vite bridge's middleware and `initDevframe`'s `handler` / `nodeMiddleware` on hosts that never see upgrade events.

`__connection.json` advertises what's bound; `backend` names the server's primary transport:

```json
{
"backend": "websocket",
"websocket": { "path": "__ws" },
"sse": { "path": "__sse" }
}
```

The SSE stream carries a keep-alive comment every 30 seconds so idle connections survive intermediaries. Both endpoints share one session space — auth trust, shared-state subscriptions, and streaming replay behave identically on either.

### Configuring

```ts
// SSE-only — hosts/proxies where the upgrade can't happen. Clients
// connect over SSE automatically (backend: 'sse').
initDevframe(def, { base: '/__my-tool/', ws: false })

// WebSocket-only — opt out of the SSE endpoint.
initDevframe(def, { base: '/__my-tool/', server, sse: false })

// Rename the SSE route.
initDevframe(def, { base: '/__my-tool/', server, sse: { route: '__events' } })
```

`ws: false` together with `sse: false` runs an RPC-less shell (`backend: 'none'`) — the SPA, discovery, and MCP routes still serve. The same options apply to `createDevServer`, `initHub`, and a definition's `cli.ws` / `cli.sse` defaults.

## What the client picks

`connectDevframe` trusts the server's advertisement: it connects over the declared primary, preferring the WebSocket when both endpoints are present. A server that binds no socket advertises SSE as its primary, so the client lands there with no probing or fallback logic.

Pin a transport explicitly when you know better than the advertisement — the typical case is an intermediary that silently strips WS upgrades, which the server cannot detect:

```ts
const client = await connectDevframe({ transport: 'sse' })

client.transport // 'websocket' | 'sse' | 'static' — what actually connected
```

Pinning a transport the server doesn't advertise rejects with a clear error. SSE endpoints resolve with the same proxy-safe rules as WebSocket ones: relative paths against `__connection.json`'s own URL, an explicit `host`/`port` only for a genuinely cross-origin endpoint.

A dropped SSE stream ends the client exactly like a closed socket — pending calls reject, the status moves to `disconnected`, and reconnecting means calling `connectDevframe` again.
2 changes: 1 addition & 1 deletion examples/hub-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each an entry in `initHub`'s `devframes` list
- **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path

Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`.
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle — the choice rides a `?transport=` URL param and reconnects the whole client host on the pinned transport.

The A11y Inspector shows a live axe-core report of this hub's own page: the host serves the plugin's in-page agent module (`a11yAgentBundlePath`) same-origin inside the hub namespace and attaches it as the a11y dock's `clientScript` (the `{ devframe, dock }` entry form); the hub client runtime — `createDevframeClientHost()` booted in `app/page.tsx` — imports it into the page, so the docked panel and the agent share the origin their BroadcastChannel rides.

Expand Down
62 changes: 59 additions & 3 deletions examples/hub-next/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,38 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
return <span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">{initial}</span>
}

// ── transport preference (`?transport=` param) ──────────────────────────────
// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the
// client's `transport` option picks one, `auto` trusting the server's
// advertisement. A closed client has no reconnect, so the toggle writes the
// preference into the URL and reloads — the whole host boots on the chosen
// transport.

const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const
type TransportPref = (typeof TRANSPORT_PREFS)[number]

function readTransportPref(): TransportPref {
const raw = new URLSearchParams(window.location.search).get('transport')
return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto'
}

function applyTransportPref(pref: TransportPref) {
const url = new URL(window.location.href)
if (pref === 'auto')
url.searchParams.delete('transport')
else
url.searchParams.set('transport', pref)
window.location.href = url.href
}

function transportLabel(pref: TransportPref): string {
return pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto'
}

export default function Page() {
const [status, setStatus] = useState<Status>({ text: 'Connecting...' })
const [transport, setTransport] = useState<string | null>(null)
const [transportPref, setTransportPref] = useState<TransportPref>('auto')
const [docks, setDocks] = useState<DevframeDockEntry[]>([])
const [commands, setCommands] = useState<DevframeCommandEntry[]>([])
const [messages, setMessages] = useState<DevframeMessageEntry[]>([])
Expand All @@ -191,12 +221,15 @@ export default function Page() {

async function run() {
try {
const rpc = await connectDevframe({ baseURL: HUB_BASE })
const pref = readTransportPref()
setTransportPref(pref)
const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: pref })
if (cancelled)
return

rpcRef.current = rpc
setStatus({ text: `Connected: backend=${rpc.connectionMeta.backend}`, kind: 'ready' })
setTransport(rpc.transport)
setStatus({ text: `Connected: transport=${rpc.transport}`, kind: 'ready' })

// Boot the framework-level client host: it builds the shared client
// context and imports each dock's client script into this page — e.g.
Expand Down Expand Up @@ -485,7 +518,30 @@ export default function Page() {
</main>
</div>

<footer className="grid grid-cols-3 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
<footer className="grid grid-cols-4 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
<section className="min-w-0">
<h2 className={titleClass}>Transport</h2>
<p className="m0 rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">
{transport
? `Connected over ${transport} (${transportPref === 'auto' ? 'auto-selected' : 'pinned'})`
: 'Connecting…'}
</p>
{/* Segmented selector (LayoutTabs variant="segment" port): a
bg-secondary track whose active trigger gets bg-base. */}
<div className="mt2.5 inline-flex gap-0.5 rounded-lg bg-secondary p0.5">
{TRANSPORT_PREFS.map(pref => (
<button
key={pref}
type="button"
onClick={() => applyTransportPref(pref)}
className={`rounded-md border-none bg-transparent px2 py0.5 text-xs font-medium cursor-pointer ${pref === transportPref ? 'bg-base color-active shadow-sm' : 'color-muted hover:color-base'}`}
>
{transportLabel(pref)}
</button>
))}
</div>
</section>

<section className="min-w-0">
<h2 className={titleClass}>Commands</h2>
<ul className="m0 flex flex-col list-none gap-1.5 p0">
Expand Down
1 change: 1 addition & 0 deletions examples/hub-next/tests/next-devframe-hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('next-devframe-hub (example)', () => {
expect(hub.connectionMeta()).toEqual({
backend: 'websocket',
websocket: { port, path: '__ws' },
sse: { path: '/__devframes/__sse' },
mcp: { path: '__mcp' },
})
})
Expand Down
2 changes: 1 addition & 1 deletion examples/hub-vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each a published `DevframeDefinition` passed to the host's `devframes` option
- **Demo Tool** / **Demo Tool B** — two trivial static SPAs that show the bare mount path

Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`.
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle — the choice rides a `?transport=` URL param and reconnects the whole client host on the pinned transport.

The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the plugin's in-page agent as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime — `createDevframeClientHost()` booted in `src/client/main.ts` — imports it into the host page. Panel and agent share the Vite origin their BroadcastChannel rides; hover a violation to ring the offending element in the hub UI.

Expand Down
10 changes: 9 additions & 1 deletion examples/hub-vite/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ <h2 class="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h
</main>
</div>

<footer class="grid grid-cols-3 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
<footer class="grid grid-cols-4 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
<section class="min-w-0">
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Transport</h2>
<p id="transport" class="m0 rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Connecting…</p>
<!-- Segmented selector (LayoutTabs variant="segment" port): a
bg-secondary track whose active trigger gets bg-base. -->
<div id="transport-toggle" class="mt2.5 inline-flex gap-0.5 rounded-lg bg-secondary p0.5"></div>
</section>

<section class="min-w-0">
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Commands</h2>
<ul id="commands" class="m0 flex flex-col list-none gap-1.5 p0"><li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Waiting for snapshot…</li></ul>
Expand Down
Loading
Loading