Skip to content
Open
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
4 changes: 4 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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/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'),
'devframe/rpc/client': r('devframe/src/rpc/client.ts'),
Expand Down Expand Up @@ -40,12 +41,15 @@ export const alias = {
'devframe/adapters/build': r('devframe/src/adapters/build.ts'),
'devframe/helpers/vite': r('devframe/src/helpers/vite.ts'),
'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'),
'devframe/initiate': r('devframe/src/adapters/initiate.ts'),
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
'@devframes/hub/client': r('hub/src/client/index.ts'),
'@devframes/hub/constants': r('hub/src/constants.ts'),
'@devframes/hub/initiate': r('hub/src/node/initiate.ts'),
'@devframes/hub/node': r('hub/src/node/index.ts'),
'@devframes/hub/types': r('hub/src/types/index.ts'),
'@devframes/hub': r('hub/src/index.ts'),
'@devframes/hub-ui': r('hub-ui/src/index.ts'),
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
'@devframes/nuxt': r('nuxt/src/index.ts'),
'@devframes/next/client': r('next/src/client.tsx'),
Expand Down
6 changes: 3 additions & 3 deletions docs/adapters/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,18 @@ process.on('SIGINT', () => handle.close().then(() => process.exit(0)))

## WebSocket endpoint

By default the RPC socket shares the HTTP server's port and binds to the `__devframe_ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option):
By default the RPC socket shares the HTTP server's port and binds to the `__ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option):

```ts
defineDevframe({
// 1. Same server, a custom route (default route is `__devframe_ws`):
// 1. Same server, a custom route (default route is `__ws`):
cli: { ws: { route: '__sockets' } },

// 2. A dedicated port on the same host:
cli: { ws: { port: 9788 } },

// 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay):
cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } },
cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } },
})
```

Expand Down
33 changes: 33 additions & 0 deletions docs/errors/DF0053.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF0053: Memoized Instance Replaced

## Message

> initDevframe("`{id}`") replaced the live instance memoized under key "`{key}`": its options changed since the previous call.

## Cause

`initDevframe` was called with a `key` that already maps to a live instance, but the option fingerprint differs from the memoized one's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `initDevframe` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts.

## Example

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

// First evaluation:
initDevframe(def, { key: 'devtools', ws: { port: 7811 } })

// A later reload with a different port replaces the live instance:
initDevframe(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
```

## Fix

This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, make the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different instances distinct keys.

## Source

- [`packages/devframe/src/adapters/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/initiate.ts) — `initDevframe` warns this before closing and replacing a memoized instance whose options fingerprint changed.
33 changes: 33 additions & 0 deletions docs/errors/DF0054.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF0054: connectionMeta() Before Instance Ready

## Message

> connectionMeta() was called before initDevframe("`{id}`") finished initializing.

## Cause

`initDevframe` is a synchronous factory that kicks off asynchronous initialization eagerly — running `def.setup`, binding the WebSocket tier, and mounting the routes. `connectionMeta()` describes the WebSocket binding, which only exists once that initialization completes; calling it earlier has nothing correct to return.

## Example

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

const devtools = initDevframe(def)
devtools.connectionMeta() // ✗ throws DF0054 — init is still in flight

await devtools.ready
devtools.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } }
```

## Fix

Await `instance.ready` (or any request through `instance.handler` — it awaits readiness internally) before reading `connectionMeta()`.

## Source

- [`packages/devframe/src/adapters/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/initiate.ts) — `initDevframe`'s `connectionMeta()` throws this while initialization is still pending.
31 changes: 31 additions & 0 deletions docs/errors/DF8000.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
outline: deep
---

# DF8000: Devframe Id Collides With a Reserved Hub Path

## Message

> Devframe id "`{id}`" collides with a reserved hub path — it cannot be mounted directly under the hub base.

## Cause

`initHub` mounts every devframe at `<base><id>/`, directly under the hub base. The filenames that live at that same level — `__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, `__mcp`, and `embedded.js` — are the hub protocol's own endpoints, so a devframe id equal to one of them would shadow the endpoint.

## Example

```ts
import { initHub } from '@devframes/hub/initiate'

initHub({
devframes: [defineDevframe({ id: '__mcp', /* … */ })], // ✗ throws DF8000
})
```

## Fix

Rename the devframe id, or mount it at a non-colliding path via `basePath` on the definition.

## Source

- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` throws this while mounting the `devframes` list.
33 changes: 33 additions & 0 deletions docs/errors/DF8001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF8001: Memoized Hub Instance Replaced

## Message

> initHub replaced the live hub instance memoized under key "`{key}`": its options changed since the previous call.

## Cause

`initHub` was called with a `key` that already maps to a live instance, but the option fingerprint differs from the memoized one's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `initHub` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts.

## Example

```ts
import { initHub } from '@devframes/hub/initiate'

// First evaluation:
initHub({ key: 'devtools', devframes: [git] })

// A later reload with a different frame list replaces the live instance:
initHub({ key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
```

## Fix

This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, keep the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different hubs distinct keys.

## Source

- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` warns this before closing and replacing a memoized instance whose options fingerprint changed.
36 changes: 36 additions & 0 deletions docs/errors/DF8002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
outline: deep
---

# DF8002: Both devframes and context Passed to initHub

## Message

> initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.

## Cause

`initHub` assembles a hub in one of two ways: **declaratively** (`devframes: [...]` — the instance creates the hub context with its own host and mounts each frame under `<base><id>/`), or **from a pre-built context** (`context: ctx` — your host already created the context and mounted the frames; the instance serves only the hub-level endpoints and transport). A `devframes` list cannot be mounted into a context whose host the instance doesn't own, so passing both is a contradiction.

## Example

```ts
// ✗ Bad
initHub({ devframes: [git], context: myCtx })

// ✓ Good — declarative:
initHub({ devframes: [git] })

// ✓ Good — bring your own context:
const ctx = await createHubContext({ host: myHost, cwd })
await mountDevframe(ctx, git)
initHub({ context: ctx })
```

## Fix

Pick one mode. Use `configure(ctx)` on the declarative mode when you need post-mount registrations (docks, commands, terminals) on the instance-created context.

## Source

- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` throws this during initialization when both options are present.
33 changes: 33 additions & 0 deletions docs/errors/DF8003.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF8003: connectionMeta() Before Hub Instance Ready

## Message

> connectionMeta() was called before initHub finished initializing.

## Cause

`initHub` is a synchronous factory that kicks off asynchronous initialization eagerly — creating the hub context, mounting every frame, and binding the WebSocket tier. `connectionMeta()` describes the WebSocket binding, which only exists once that initialization completes; calling it earlier has nothing correct to return.

## Example

```ts
import { initHub } from '@devframes/hub/initiate'

const hub = initHub({ devframes: [git] })
hub.connectionMeta() // ✗ throws DF8003 — init is still in flight

await hub.ready
hub.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } }
```

## Fix

Await `instance.ready` (or any request through `instance.handler` — it awaits readiness internally) before reading `connectionMeta()`.

## Source

- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub`'s `connectionMeta()` throws this while initialization is still pending.
4 changes: 2 additions & 2 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,12 @@ With caching on, `query` / `static` function responses are memoized per argument

## Discovery (`__connection.json`)

Devframe writes a JSON descriptor at `<base>/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`<base>__devframe_ws`) next to the meta file — and advertises it as a relative path:
Devframe writes a JSON descriptor at `<base>/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`<base>__ws`) next to the meta file — and advertises it as a relative path:

```json
{
"backend": "websocket",
"websocket": { "path": "__devframe_ws" }
"websocket": { "path": "__ws" }
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/helpers/vite-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default defineConfig({
## Modes

- **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__<id>/` 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 `<base>__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 `/__devframe_ws` route.
- **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 `<base>__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.

Expand Down
6 changes: 3 additions & 3 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,21 @@
"entry": [
"src/{index,constants}.ts",
"src/helpers/vite.ts",
"src/adapters/{build,cac,cli,dev,embedded}.ts",
"src/adapters/{build,cac,cli,dev,embedded,initiate}.ts",
"src/adapters/mcp/index.ts",
"src/client/index.ts",
"src/node/index.ts",
"src/node/{auth,hub-internals}/index.ts",
"src/recipes/{common-rpc-functions,interactive-auth,open-helpers}.ts",
"src/rpc/{index,client,server}.ts",
"src/rpc/dump/index.ts",
"src/rpc/transports/{ws-client,ws-server}.ts",
"src/rpc/transports/{ws-bun,ws-client,ws-server}.ts",
"src/types/index.ts",
"src/utils/*.ts"
]
},
"packages/hub": {
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts"]
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/initiate.ts"]
},
"packages/json-render": {
// `src/node/index.ts` is already picked up via `tsdown.config.ts`
Expand Down
2 changes: 2 additions & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"./client": "./dist/client/index.mjs",
"./constants": "./dist/constants.mjs",
"./helpers/vite": "./dist/helpers/vite.mjs",
"./initiate": "./dist/adapters/initiate.mjs",
"./node": "./dist/node/index.mjs",
"./node/auth": "./dist/node/auth.mjs",
"./node/hub-internals": "./dist/node/hub-internals.mjs",
Expand All @@ -39,6 +40,7 @@
"./rpc/client": "./dist/rpc/client.mjs",
"./rpc/dump": "./dist/rpc/dump.mjs",
"./rpc/server": "./dist/rpc/server.mjs",
"./rpc/transports/ws-bun": "./dist/rpc/transports/ws-bun.mjs",
"./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs",
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
"./types": "./dist/types/index.mjs",
Expand Down
18 changes: 9 additions & 9 deletions packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))
function connectWsClient(host: string, port: number, authToken?: string) {
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
{} as DevframeRpcClientFunctions,
{ channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) },
{ channel: createWsRpcChannel({ url: `ws://${host}:${port}/__ws`, authToken }) },
)
}

Expand Down Expand Up @@ -64,7 +64,7 @@ describe('adapters/dev', () => {
const meta = await res.json()
// Proxy-safe: the WS endpoint is advertised as a same-origin route
// relative to `__connection.json`, never a baked-in host/port.
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })
}
finally {
await handle.close()
Expand Down Expand Up @@ -129,7 +129,7 @@ describe('adapters/dev', () => {

try {
// Connects on the bound route.
const ok = new WebSocket(`ws://${host}:${port}/__devframe_ws`)
const ok = new WebSocket(`ws://${host}:${port}/__ws`)
await expect(new Promise((resolve, reject) => {
ok.on('open', () => resolve('open'))
ok.on('error', reject)
Expand Down Expand Up @@ -205,11 +205,11 @@ describe('adapters/dev', () => {
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
expect(meta).toEqual({
backend: 'websocket',
websocket: { port: wsPort, path: '__devframe_ws' },
websocket: { port: wsPort, path: '__ws' },
})

// The socket is reachable on its own port, rooted at `/<route>`.
const ok = new WebSocket(`ws://${host}:${wsPort}/__devframe_ws`)
const ok = new WebSocket(`ws://${host}:${wsPort}/__ws`)
await expect(new Promise((resolve, reject) => {
ok.on('open', () => resolve('open'))
ok.on('error', reject)
Expand All @@ -234,7 +234,7 @@ describe('adapters/dev', () => {
homepage: 'https://example.test',
description: 'Test devframe.',
setup: () => {},
cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } },
cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } },
})
const host = '127.0.0.1'
const port = await getPort({ port: 19860, host })
Expand All @@ -244,7 +244,7 @@ describe('adapters/dev', () => {
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
expect(meta).toEqual({
backend: 'websocket',
websocket: 'wss://devtools.example.com/relay/__devframe_ws',
websocket: 'wss://devtools.example.com/relay/__ws',
})
}
finally {
Expand Down Expand Up @@ -276,7 +276,7 @@ describe('adapters/dev', () => {
const res = await fetch(`http://${host}:${port}/__connection.json`)
expect(res.ok).toBe(true)
const meta = await res.json()
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })

// The SPA mount is absent — without a distDir, no static handler
// is wired, so the basePath returns a 404 from h3 instead of an
Expand Down Expand Up @@ -559,7 +559,7 @@ describe('adapters/dev', () => {
})

try {
const ws = new WebSocket(`ws://${host}:${port}/__devframe_ws`)
const ws = new WebSocket(`ws://${host}:${port}/__ws`)
await new Promise<void>((resolve, reject) => {
ws.on('open', () => resolve())
ws.on('error', reject)
Expand Down
Loading