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
163 changes: 163 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,163 @@
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 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: () => 'connected',
}))
ctx.rpc.register(defineRpcFunction({
name: 'test:json',
type: 'query',
jsonSerializable: true,
handler: (value: unknown) => value,
}))
},
})

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 callThroughHandle(handle, '/', 'sse')
}
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())
expect(handle.connectionMeta()).toMatchObject(httpMeta)
expect(handle.connectionMeta().jsonSerializableMethods).toContain('test:json')
expect(resolveClientTransport('auto', handle.connectionMeta())).toBe('websocket')
await callThroughHandle(handle, basePath, 'sse')
await callThroughHandle(handle, basePath, 'websocket')
}
finally {
await handle.close()
}
await expectPortReleased(handle.port)
})

it('preserves WebSocket-only operation', async () => {
const handle = await createDevServer(definition, { ...options, sse: false })
try {
expect(handle.connectionMeta().sse).toBeUndefined()
await callThroughHandle(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((await fetch(`${handle.origin}/__connection.json`)).status).toBe(200)
}
finally {
await handle.close()
}
await expectPortReleased(handle.port)
})
})

async function callThroughHandle(handle: Awaited<ReturnType<typeof createDevServer>>, basePath: string, transport: 'sse' | 'websocket') {
vi.stubGlobal('location', new URL(`${handle.origin}${basePath}`))
vi.stubGlobal('navigator', { userAgent: 'vitest' })
const client = await getDevframeRpcClient({
connection: {
connectionMeta: handle.connectionMeta(),
metaBaseUrl: `${handle.origin}${basePath}__connection.json`,
},
transport,
otpParam: false,
simpleAuth: false,
})
try {
await client.ensureTrusted(2000)
expect(client.transport).toBe(transport)
await expect((client.call as any)('test:probe')).resolves.toBe('connected')
expect(handle.rpcGroup.clients.length).toBeGreaterThan(0)
if (handle.connectionMeta().jsonSerializableMethods?.includes('test:json')) {
await expect((client.call as any)('test:json', { value: 42 })).resolves.toEqual({ value: 42 })
await expect((client.call as any)('test:json', new Map([
['key', 42],
]))).rejects.toThrow()
}
}
finally {
client.close?.()
}
}
8 changes: 5 additions & 3 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,7 @@ export async function createDevServer(
}

const internals = getInstanceInternals(devframe)
// Every dev-server configuration binds a local transport (`server` is
// always passed), so the bound-transport handle is always present.
// The supplied HTTP server has a handle even when WS is disabled.
const transport = internals.started!

await options.onReady?.({ origin, port, app })
Expand All @@ -232,7 +231,10 @@ export async function createDevServer(
app,
ws: transport.ws,
rpcGroup: transport.rpcGroup,
connectionMeta: transport.connectionMeta,
connectionMeta: () => ({
...transport.connectionMeta(),
...devframe.connectionMeta(),
}),
async close() {
// Instance teardown removes the registry record, detaches the WS
// transport (and closes any dedicated-port socket server), and disposes
Expand Down
9 changes: 4 additions & 5 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,11 +807,10 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
throw lastError
}

// Shared-server tier: bind the WS upgrade on the host's own server, or
// (when a native runtime can't re-host that foreign `node:http` server) fall
// back to a WS-less `StartedServer` and let SSE carry the transport.
// Shared HTTP servers retain a handle and RPC group even when WS is
// disabled or a native runtime cannot bind upgrades on the supplied server.
async function startServerTier(): Promise<TierBinding> {
if (nativeRuntime && !ws.url) {
if (wsDisabled || (nativeRuntime && !ws.url)) {
const core = await ensureCore()
started = {
origin: currentOrigin() ?? '',
Expand Down Expand Up @@ -850,7 +849,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
wsAvailable: !wsDisabled,
}
}
if (tier === 'server')
if (tier === 'server' || (tier === 'disabled' && options.server))
return startServerTier()
if (tier === 'external')
return { websocketMeta: ws.url!, wsAvailable: !wsDisabled }
Expand Down
Loading