Skip to content
Draft
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
6 changes: 4 additions & 2 deletions docs/content/2.adapters/3.dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const handle = await createDevServer(myDevframe, {
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
```

Returns a `StartedServer`: origin, port, h3 app, WS server, RPC group, `close()`.
The returned `StartedServer` exposes `origin`, `port`, `app`, `rpcGroup`, `connectionMeta()` and `close()`. Its `ws` property is optional. Call `close()` to release the HTTP listener even with WebSocket disabled.

Each call to `handle.connectionMeta()` returns a fresh JSON snapshot. Resolve local WebSocket, SSE and MCP paths against `handle.origin`. When reading `__connection.json` over HTTP, resolve its relative paths against the document URL.

| Option | Default | Description |
|--------|---------|-------------|
Expand All @@ -36,7 +38,7 @@ Returns a `StartedServer`: origin, port, h3 app, WS server, RPC group, `close()`

## WebSocket endpoint

The RPC socket shares the HTTP port at `__ws`, advertised *relative*. Configure `def.cli.ws`:
By default the RPC socket shares the HTTP port. The discovery document advertises its relative path as `__ws`. Configure `def.cli.ws`:

| Field | Scenario | Advertised `websocket` |
|-------|----------|------------------------|
Expand Down
1 change: 1 addition & 0 deletions examples/custom-hub-next/tests/next-devframe-hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('next-devframe-hub (example)', () => {

expect(hub.connectionMeta()).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.arrayContaining(['devframes:plugin:assets:list']),
websocket: { port, path: '__ws' },
sse: { path: '/__devframes/__sse' },
mcp: { path: '__mcp' },
Expand Down
258 changes: 258 additions & 0 deletions packages/devframe/src/adapters/__tests__/dev-transports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import type { CreateDevServerOptions } from '../dev'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineDevframe, defineRpcFunction } from 'devframe'
import { getDevframeRpcClient, resolveClientTransport } from 'devframe/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readDevframeInstances } from '../../node/instance-registry'
import { createDevServer } from '../dev'

const jsonHandler = vi.fn((value: unknown) => value)

const definition = defineDevframe({
id: 'dev-transports-test',
name: 'Dev transports test',
version: '0.0.0',
packageName: 'devframe-test',
homepage: 'https://example.test',
description: 'Test devframe.',
setup(ctx) {
ctx.rpc.register(defineRpcFunction({
name: 'test:probe',
type: 'query',
handler: (value: unknown) => value,
}))
ctx.rpc.register(defineRpcFunction({
name: 'test:json',
type: 'query',
jsonSerializable: true,
handler: jsonHandler,
}))
},
})

const options = {
host: '127.0.0.1',
port: 0,
auth: false,
mcp: false,
openBrowser: false,
} as const

async function expectPortReleased(port: number) {
const listener = createServer()
await new Promise<void>((resolve, reject) => {
listener.once('error', reject)
listener.listen(port, options.host, resolve)
})
await new Promise<void>((resolve, reject) => listener.close(error => error
? reject(error)
: resolve()))
}

afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})

describe('dev server transport handle', () => {
it('returns and disposes an SSE-only handle and its registry entry', async () => {
const registryDir = await mkdtemp(join(tmpdir(), 'dev-transports-'))
vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir)
vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0')
try {
const handle = await createDevServer(definition, { ...options, ws: false })
try {
expect(handle.ws).toBeUndefined()
expect(handle.rpcGroup).toBeDefined()
expect(handle.connectionMeta()).toMatchObject({
backend: 'sse',
sse: { path: '/__sse' },
})
await vi.waitFor(() => expect(readDevframeInstances({ instancesDir: registryDir })).toHaveLength(1))
await checkRpcCalls(handle, '/', 'sse')
await checkRpcCalls(handle, '/', 'sse', 'http')
}
finally {
await handle.close()
}
expect(readDevframeInstances({ instancesDir: registryDir })).toEqual([])
await expectPortReleased(handle.port)
}
finally {
await rm(registryDir, { recursive: true, force: true })
}
})

it.each([
{ basePath: '/', sse: true },
{
basePath: '/nested/tool/',
sse: { route: '__events' },
},
] satisfies CreateDevServerOptions[])('advertises and connects both transports at $basePath', async ({ basePath, sse }) => {
const handle = await createDevServer(definition, { ...options, basePath, sse })
try {
const httpMeta = await fetch(`${handle.origin}${basePath}__connection.json`).then(response => response.json())
const sseRoute = typeof sse === 'object'
? sse.route
: '__sse'
expect(httpMeta).toMatchObject({
websocket: { path: '__ws' },
sse: { path: sseRoute },
})
const snapshot = handle.connectionMeta()
expect(snapshot).toEqual({
...httpMeta,
websocket: { path: `${basePath}__ws` },
sse: { path: `${basePath}${sseRoute}` },
})
expect(typeof snapshot.websocket).toBe('object')
if (typeof snapshot.websocket === 'object')
snapshot.websocket.path = '/viewer/__ws'
expect(handle.connectionMeta().websocket).toEqual({ path: `${basePath}__ws` })
expect(await fetch(`${handle.origin}${basePath}__connection.json`).then(response => response.json())).toEqual(httpMeta)
expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json')
expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket')
await checkRpcCalls(handle, basePath, 'sse')
await checkRpcCalls(handle, basePath, 'websocket')
await checkRpcCalls(handle, basePath, 'websocket', 'http')
}
finally {
await handle.close()
}
await expectPortReleased(handle.port)
})

it('initializes MCP from both descriptors under a nested mount', async () => {
const basePath = '/nested/tool/'
const handle = await createDevServer(definition, {
...options,
basePath,
mcp: { path: 'tools/mcp' },
})
try {
const discovery = new URL(`${basePath}__connection.json`, handle.origin)
const httpMeta = await fetch(discovery).then(response => response.json())
expect(httpMeta.mcp.path).toBe('tools/mcp')

const addresses = [
new URL(httpMeta.mcp.path, discovery),
new URL(handle.connectionMeta().mcp!.path, handle.origin),
]
for (const address of addresses) {
const response = await fetch(address, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'origin': handle.origin,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '0.0.0' },
},
}),
})
expect(response.status).toBe(200)
expect(await response.text()).toContain('"serverInfo"')
}
}
finally {
await handle.close()
}
})

it('returns static configuration in the same JSON form as HTTP discovery', async () => {
const url = new URL('https://example.test/help')
const handle = await createDevServer(defineDevframe({
...definition,
setup(ctx) {
Object.assign(ctx.staticConfig, {
help: { url },
})
},
}), options)
try {
const discovery = await fetch(new URL('/__connection.json', handle.origin)).then(response => response.json())
expect(discovery.configs).toEqual({
help: { url: url.href },
})
expect(handle.connectionMeta().configs).toEqual(discovery.configs)
}
finally {
await handle.close()
}
})

it('preserves WebSocket-only operation', async () => {
const handle = await createDevServer(definition, { ...options, sse: false })
try {
expect(handle.connectionMeta().sse).toBeUndefined()
await checkRpcCalls(handle, '/', 'websocket')
}
finally {
await handle.close()
}
})

it('returns a closable HTTP handle with both RPC transports disabled', async () => {
const handle = await createDevServer(definition, { ...options, ws: false, sse: false })
try {
expect(handle.ws).toBeUndefined()
expect(handle.rpcGroup).toBeDefined()
expect(handle.connectionMeta()).toMatchObject({ backend: 'none' })
expect(handle.connectionMeta().websocket).toBeUndefined()
expect(handle.connectionMeta().sse).toBeUndefined()
expect(handle.connectionMeta()).not.toHaveProperty('jsonSerializableMethods')
expect((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200)
}
finally {
await handle.close()
}
await expectPortReleased(handle.port)
})
})

async function checkRpcCalls(handle: Awaited<ReturnType<typeof createDevServer>>, basePath: string, transport: 'sse' | 'websocket', source: 'handle' | 'http' = 'handle') {
vi.stubGlobal('location', new URL(`${handle.origin}${basePath}`))
vi.stubGlobal('navigator', { userAgent: 'vitest' })
const metaBaseUrl = `${handle.origin}${basePath}__connection.json`
const connectionMeta = source === 'http'
? await fetch(metaBaseUrl).then(response => response.json())
: handle.connectionMeta()
const client = await getDevframeRpcClient({
connection: {
connectionMeta,
metaBaseUrl: source === 'http'
? metaBaseUrl
: handle.origin,
},
transport,
otpParam: false,
simpleAuth: false,
})
try {
await client.ensureTrusted(2000)
expect(client.transport).toBe(transport)
const map = new Map([
['key', 42],
])
await expect((client.call as any)('test:probe', map)).resolves.toEqual(map)
expect(handle.rpcGroup.clients.length).toBeGreaterThan(0)
jsonHandler.mockClear()
await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 })
await expect((client.call as any)('test:json', map)).rejects.toThrow(/Map/)
expect(jsonHandler).toHaveBeenCalledTimes(1)
}
finally {
client.close?.()
}
}
5 changes: 5 additions & 0 deletions packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ describe('adapters/dev', () => {
// relative to `__connection.json`, never a baked-in host/port.
expect(meta).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.any(Array),
websocket: { path: '__ws' },
sse: { path: '__sse' },
})
Expand Down Expand Up @@ -250,6 +251,7 @@ describe('adapters/dev', () => {
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
expect(meta).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.any(Array),
websocket: { path: '__sockets' },
sse: { path: '__sse' },
})
Expand Down Expand Up @@ -290,6 +292,7 @@ describe('adapters/dev', () => {
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
expect(meta).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.any(Array),
websocket: { port: wsPort, path: '__ws' },
sse: { path: '__sse' },
})
Expand Down Expand Up @@ -334,6 +337,7 @@ describe('adapters/dev', () => {
// endpoint stays same-origin and rides the tunnel like the meta itself.
expect(meta).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.any(Array),
websocket: 'wss://devtools.example.com/relay/__ws',
sse: { path: '__sse' },
})
Expand Down Expand Up @@ -462,6 +466,7 @@ describe('adapters/dev', () => {
const meta = await res.json()
expect(meta).toEqual({
backend: 'websocket',
jsonSerializableMethods: expect.any(Array),
websocket: { path: '__ws' },
sse: { path: '__sse' },
})
Expand Down
Loading
Loading