diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49c891e3..da7c5a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,38 @@ jobs: lint: pnpm run lint && pnpm run knip build-for-lint: true + # The RPC transport binds a native WebSocket on Bun/Deno (crossws's + # Bun/Deno adapters over `Bun.serve` / `Deno.serve`) and falls back to SSE + # for a shared foreign `node:http` server. This runs the cross-runtime smoke + # test under each runtime so that binding can't regress (issue #317). + runtime: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + runtime: [bun, deno] + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Set up Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + - name: Set up Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: pnpm install --frozen-lockfile + - name: Build devframe + run: pnpm --filter devframe run build + - name: Run the ${{ matrix.runtime }} RPC transport smoke test + run: pnpm run test:runtime:${{ matrix.runtime }} + e2e: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/docs/content/6.errors/DF0075.md b/docs/content/6.errors/DF0075.md new file mode 100644 index 00000000..d629d038 --- /dev/null +++ b/docs/content/6.errors/DF0075.md @@ -0,0 +1,32 @@ +--- +title: 'DF0075: No RPC Transport On This Runtime' +description: 'On Bun/Deno a shared server needs crossws Node adapter and SSE is disabled, so no RPC transport is advertised.' +--- + +## Message + +> On {runtime} the shared server's WebSocket upgrade needs crossws's Node adapter, which refuses to run off Node — and SSE is disabled, so this instance advertises no RPC transport at all. + +## Cause + +Sharing a host's `node:http` server (the `server` tier) drives the WebSocket upgrade through crossws's Node adapter, which runs only on Node. On Bun and Deno the socket falls back to the SSE endpoint — but here `sse: false` turned that endpoint off too, so the instance has no way for a client to reach its RPC surface. + +## Example + +```ts +import { initHub } from '@devframes/hub/initiate' + +// Running on Bun/Deno, sharing the host's node:http server: +const hub = initHub({ + server: viteHttpServer, + sse: false, // ✗ removes the only transport left on Bun/Deno +}) +``` + +## Fix + +Keep the SSE endpoint enabled (drop `sse: false`) so clients connect over it on Bun/Deno, or move the socket to a side-car — `ws: { sidecar: true }` binds the native WebSocket adapter (`Bun.serve` / `Deno.serve`) on its own port, where a real WebSocket works. + +## 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 warns this when a shared-server WebSocket binding falls back on Bun/Deno and the SSE endpoint is disabled, for both `initDevframe` and `initHub`. diff --git a/docs/content/6.errors/DF0076.md b/docs/content/6.errors/DF0076.md new file mode 100644 index 00000000..413a684b --- /dev/null +++ b/docs/content/6.errors/DF0076.md @@ -0,0 +1,30 @@ +--- +title: 'DF0076: WebSocket Upgrade Unsupported On This Runtime' +description: 'attach / handleUpgrade drive a raw node:http upgrade into crossws Node adapter, which refuses to run on Bun/Deno.' +--- + +## Message + +> `attach` / `handleUpgrade` drive a raw `node:http` upgrade into crossws's Node adapter, which refuses to run on {runtime}. + +## Cause + +`attach(server)` and `handleUpgrade(req, socket, head)` hand a raw `node:http` upgrade socket to crossws's Node adapter. That adapter runs only on Node — Bun and Deno expose WebSockets as `fetch` upgrades through `Bun.serve` / `Deno.serve` instead, so there is no `node:http` upgrade socket for the adapter to take over. + +## Example + +```ts +import { initHub } from '@devframes/hub/initiate' + +// Running on Bun/Deno: +const hub = initHub({ base: '/__devframes/' }) +hub.attach(myNodeHttpServer) // ✗ throws DF0076 on Bun/Deno +``` + +## Fix + +On Bun/Deno, serve the advertised `__ws` route from `Bun.serve` / `Deno.serve` and complete the upgrade with `attachBunWsTransport` / `attachDenoWsTransport` (see the `hub-deno-minimal` example), or connect over the SSE endpoint instead — it rides the instance's ordinary HTTP surface and needs no upgrade wiring. A side-car (`ws: { sidecar: true }`) also binds the native WebSocket adapter for you on its own port. + +## 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` on Bun/Deno, for both `initDevframe` and `initHub`. diff --git a/package.json b/package.json index d4b08ecc..c3a44669 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "test": "pnpm run build && vitest", "test:e2e": "pnpm run build && playwright test", "test:e2e:ui": "pnpm run build && playwright test --ui", + "test:runtime": "tsx packages/devframe/test/runtime-smoke.ts", + "test:runtime:bun": "bun packages/devframe/test/runtime-smoke.ts", + "test:runtime:deno": "deno run -A --node-modules-dir=manual packages/devframe/test/runtime-smoke.ts", "test:ecosystem": "tsx scripts/ecosystem-ci.ts", "release": "bumpp -r", "typecheck": "pnpm run verify:typecheck-coverage && turbo run typecheck", diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index a6d30995..1991132e 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -199,5 +199,15 @@ export const diagnostics = defineDiagnostics({ `\`rpc.snapshot\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`, fix: 'Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump.', }, + DF0075: { + why: (p: { runtime: string }) => + `On ${p.runtime} the shared server's WebSocket upgrade needs crossws's Node adapter, which refuses to run off Node — and SSE is disabled, so this instance advertises no RPC transport at all.`, + fix: 'Keep the SSE endpoint enabled (drop `sse: false`) so clients connect over it on Bun/Deno, or move the socket to a side-car (`ws: { sidecar: true }`) which binds the native WebSocket adapter on its own port.', + }, + DF0076: { + why: (p: { runtime: string }) => + `\`attach\` / \`handleUpgrade\` drive a raw \`node:http\` upgrade into crossws's Node adapter, which refuses to run on ${p.runtime}.`, + fix: 'On Bun/Deno, serve the advertised `__ws` route from `Bun.serve` / `Deno.serve` with `attachBunWsTransport` / `attachDenoWsTransport` (see the hub-deno-minimal example), or connect over the SSE endpoint instead.', + }, }, }) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 2b76e587..2f930f41 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -20,6 +20,7 @@ import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' import { createInteractiveAuth } from '../recipes/interactive-auth' import { diagnostics } from './diagnostics' import { getInternalContext } from './hub-internals/context' +import { detectServerRuntime } from './runtime' import { formatHostForUrl, normalizeHttpServerUrl } from './utils' /** @@ -159,6 +160,171 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise void | Promise +} +interface BunGlobal { + serve: (options: { + port?: number + hostname?: string + fetch: (request: Request, server: BunServerLike) => Response | undefined | Promise + websocket?: unknown + }) => BunServerLike +} + +/** Structural view of `Deno.serve`, for the same reason. */ +interface DenoServerLike { + addr: { port: number, hostname: string } + shutdown: () => Promise +} +interface DenoGlobal { + serve: ( + options: { port?: number, hostname?: string, onListen?: (addr: { port: number }) => void }, + handler: (request: Request, info: unknown) => Response | Promise, + ) => DenoServerLike +} + +/** + * Whether a request is a WebSocket upgrade aimed at `path` — the `fetch`-side + * equivalent of the Node transport's `upgrade`-event path filter, used by the + * native ({@link bindNativeHttpAndWs}) tiers to route only the RPC route to + * the socket and leave every other request to the h3 app. + */ +function isWsUpgradeRequest(request: Request, path: string | undefined): boolean { + if ((request.headers.get('upgrade') ?? '').toLowerCase() !== 'websocket') + return false + if (!path) + return true + try { + return samePath(new URL(request.url).pathname, path) + } + catch { + return false + } +} + +/** + * The Bun/Deno counterpart to {@link bindHttpAndWs}: own and listen on a + * native `fetch`-upgrade server (`Bun.serve` / `Deno.serve`) with crossws's + * Bun/Deno adapter driving the RPC socket, since crossws's Node adapter (and + * the `node:http` `upgrade` event it needs) refuses to run off Node. Only the + * owns-a-server tiers reach here — a shared foreign `node:http` server can't be + * re-hosted on a native runtime, so that path falls back to SSE instead. + */ +async function bindNativeHttpAndWs( + runtime: 'bun' | 'deno', + options: BindHttpAndWsOptions, +): Promise { + const { context, port, core } = options + const bindHost = options.host + const app = new H3App() + const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl + const wsPath = options.path + + const fail = async (closeWs: () => Promise, error: unknown): Promise => { + await closeWs().catch(() => {}) + throw diagnostics.DF0052({ + host: bindHost, + port, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + + let resolvedPort: number + let closeWs: () => Promise + let closeServer: () => Promise + + if (runtime === 'bun') { + const { attachBunWsTransport } = await import('devframe/rpc/transports/ws-bun') + const tier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins }) + closeWs = tier.close + const Bun = (globalThis as unknown as { Bun: BunGlobal }).Bun + let server: BunServerLike + try { + server = Bun.serve({ + port, + hostname: bindHost, + fetch: async (request, srv) => + isWsUpgradeRequest(request, wsPath) + ? await tier.handleUpgrade(request, srv) + : await app.fetch(request), + websocket: tier.websocket, + }) + } + catch (error) { + return await fail(closeWs, error) + } + resolvedPort = server.port + closeServer = async () => { + await server.stop(true) + } + } + else { + const { attachDenoWsTransport } = await import('devframe/rpc/transports/ws-deno') + const tier = await attachDenoWsTransport(core, { allowedOrigins: options.allowedOrigins }) + closeWs = tier.close + const Deno = (globalThis as unknown as { Deno: DenoGlobal }).Deno + let server: DenoServerLike + try { + server = Deno.serve( + // A no-op `onListen` suppresses Deno's default "Listening on…" banner, + // keeping the transport headless like every other tier. + { port, hostname: bindHost, onListen: () => {} }, + async (request, info) => + isWsUpgradeRequest(request, wsPath) + ? await tier.handleUpgrade(request, info) + : await app.fetch(request), + ) + } + catch (error) { + return await fail(closeWs, error) + } + resolvedPort = server.addr.port + closeServer = async () => { + await server.shutdown() + } + } + + const origin = normalizeHttpServerUrl(bindHost, resolvedPort) + const internal = getInternalContext(context) + const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ''}` + internal.setWsEndpoint({ url: wsUrl }) + + function connectionMeta(): ConnectionMeta { + const jsonSerializableMethods: string[] = [] + for (const def of rpcHost.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + return { backend: 'websocket', websocket: { path: options.path }, jsonSerializableMethods } + } + + return { + origin, + port: resolvedPort, + app, + ws: undefined, + rpcGroup: core.rpcGroup, + connectionMeta, + async close() { + // Stop the native server before the WS tier: closing the socket while a + // peer is mid-disconnect (a client that just sent its close frame) + // deadlocks `Bun.serve().stop()` and crossws's peer close on Bun. + // Dropping the server's connections first makes the tier close a no-op. + await closeServer() + await closeWs() + if (getInternalContext(context).wsEndpoint?.url === wsUrl) + getInternalContext(context).setWsEndpoint(undefined) + }, + } +} + /** * How the instance's RPC socket is bound: * @@ -343,6 +509,15 @@ export function createInstanceShell( const baseNoSlash = withoutTrailingSlash(base) const app = options.app ?? new H3App() + // Bun and Deno drive WebSockets through a native `fetch`-upgrade server + // (`Bun.serve` / `Deno.serve`) rather than the `node:http` `upgrade` event + // crossws's Node adapter needs — that adapter throws on sight anywhere off + // Node. The tiers where devframe owns its server use the matching native + // adapter; a shared foreign `node:http` server (the `server` tier) can't be + // re-hosted, so it falls back to the runtime-agnostic SSE transport. + const runtime = detectServerRuntime() + const nativeRuntime = runtime !== 'node' + const wsDisabled = options.ws === false const ws: DevframeWsOptions = options.ws === false ? {} : options.ws ?? {} const route = withoutLeadingSlash(ws.route ?? DEVFRAME_WS_ROUTE) @@ -509,14 +684,22 @@ export function createInstanceShell( */ async function startSidecar(core: ContextRpcServer): Promise { const sidecarHost = options.host ?? 'localhost' - const start = (port: number): Promise => bindHttpAndWs({ - context: ctx, - core, - host: sidecarHost, - port, - path: withLeadingSlash(route), - allowedOrigins: options.allowedOrigins, - }) + const start = (port: number): Promise => { + const bindOptions: BindHttpAndWsOptions = { + context: ctx, + core, + host: sidecarHost, + port, + path: withLeadingSlash(route), + allowedOrigins: options.allowedOrigins, + } + // A side-car is devframe's own dedicated server, so on Bun/Deno it binds + // the native adapter for a real WebSocket; on Node it takes crossws's + // Node adapter over a `node:http` server. + return nativeRuntime + ? bindNativeHttpAndWs(runtime as 'bun' | 'deno', bindOptions) + : bindHttpAndWs(bindOptions) + } if (ws.port != null) return await start(ws.port) const { getPort } = await import('get-port-please') @@ -545,24 +728,50 @@ export function createInstanceShell( // forwards to whatever this instance bound locally. resolvedAuth = tier === 'external' ? false : resolveAuth() let websocketMeta: ConnectionMeta['websocket'] | undefined + // Whether a WebSocket transport is actually reachable for this tier on + // this runtime. It drops to `false` only when a shared foreign `node:http` + // server would need crossws's Node adapter on Bun/Deno — there the socket + // gives way to SSE below. + let wsAvailable = !wsDisabled if (tier === 'sidecar') { started = await startSidecar(await ensureCore()) websocketMeta = { port: started.port, path: route } } else if (tier === 'server') { - // Shared upgrade on the host's own server at `` — zero - // extra ports, proxy/HTTPS friendly. - started = await bindHttpAndWs({ - context: ctx, - core: await ensureCore(), - host: options.host ?? 'localhost', - port: 0, - server: options.server, - path: routePath, - allowedOrigins: options.allowedOrigins, - destroyUnmatched: options.destroyUnmatchedUpgrades, - }) - websocketMeta = { path: advertisedPath } + if (nativeRuntime && !ws.url) { + // crossws's Node adapter can't attach to the host's foreign + // `node:http` server on Bun/Deno, and that server isn't ours to + // re-host natively — fall back to SSE (mounted below on the shell's + // own app). Build the RPC core now so the SSE transport shares it, + // and expose a WS-less `StartedServer` so adapters that read + // `internals.started` (e.g. `createDevServer`) keep working. + const core = await ensureCore() + wsAvailable = false + started = { + origin: currentOrigin() ?? '', + port: 0, + app, + ws: undefined, + rpcGroup: core.rpcGroup, + connectionMeta: () => meta ?? options.onMetaUnavailable(), + close: async () => {}, + } + } + else { + // Shared upgrade on the host's own server at `` — zero + // extra ports, proxy/HTTPS friendly. + started = await bindHttpAndWs({ + context: ctx, + core: await ensureCore(), + host: options.host ?? 'localhost', + port: 0, + server: options.server, + path: routePath, + allowedOrigins: options.allowedOrigins, + destroyUnmatched: options.destroyUnmatchedUpgrades, + }) + websocketMeta = { path: advertisedPath } + } } else if (tier === 'external') { websocketMeta = ws.url! @@ -582,9 +791,15 @@ export function createInstanceShell( respondWith(event, await (await ensureSse()).handler(event.req)))) } + // A shared-server WS binding that fell back is only truly transportless + // when SSE is off too — surface that so a Bun/Deno host knows to keep SSE + // enabled (or move the socket to a side-car). + if (!wsDisabled && !wsAvailable && !sseEnabled) + diagnostics.DF0075({ runtime }, { method: 'warn' }) + meta = { - backend: wsDisabled ? (sseEnabled ? 'sse' : 'none') : 'websocket', - ...(websocketMeta !== undefined ? { websocket: websocketMeta } : {}), + backend: (!wsDisabled && wsAvailable) ? 'websocket' : (sseEnabled ? 'sse' : 'none'), + ...(wsAvailable && websocketMeta !== undefined ? { websocket: websocketMeta } : {}), ...(sseEnabled ? { sse: { path: advertisedSsePath } } : {}), ...(result.mcp ? { mcp: result.mcp } : {}), } @@ -696,6 +911,13 @@ export function createInstanceShell( throw diagnostics.DF0056({ url: ws.url! }) if (tier !== 'unbound') throw diagnostics.DF0055({ tier }) + // `attach` / `handleUpgrade` hand a raw `node:http` socket to crossws's + // Node adapter, which refuses to run on Bun/Deno. Those runtimes serve WS + // through a native `fetch`-upgrade server instead: answer the advertised + // `__ws` route with `attach{Bun,Deno}WsTransport` from `Bun.serve` / + // `Deno.serve`, or connect over SSE. + if (nativeRuntime) + throw diagnostics.DF0076({ runtime }) } /** diff --git a/packages/devframe/src/node/runtime.ts b/packages/devframe/src/node/runtime.ts new file mode 100644 index 00000000..e4710b84 --- /dev/null +++ b/packages/devframe/src/node/runtime.ts @@ -0,0 +1,22 @@ +/** + * The server-side JavaScript runtime devframe is executing under. Only the + * three runtimes with a first-party crossws WebSocket adapter are named; every + * other environment reports `node` and takes the `node:http` path. + */ +export type ServerRuntime = 'node' | 'bun' | 'deno' + +/** + * Detect the current server runtime from its runtime-specific global. The + * WebSocket binding branches on this: Bun and Deno expose their sockets as + * `fetch` upgrades through `Bun.serve` / `Deno.serve` (crossws's Bun/Deno + * adapters), while the `node:http` upgrade event — and crossws's Node adapter, + * which refuses to run anywhere else — is Node-only. + */ +export function detectServerRuntime(): ServerRuntime { + const g = globalThis as { Deno?: unknown, Bun?: unknown } + if (typeof g.Deno !== 'undefined') + return 'deno' + if (typeof g.Bun !== 'undefined') + return 'bun' + return 'node' +} diff --git a/packages/devframe/test/runtime-smoke.ts b/packages/devframe/test/runtime-smoke.ts new file mode 100644 index 00000000..7d9db630 --- /dev/null +++ b/packages/devframe/test/runtime-smoke.ts @@ -0,0 +1,157 @@ +/* eslint-disable no-console */ +import type { DevframeDefinition } from 'devframe/types' +import { createServer } from 'node:http' +import process from 'node:process' +import { initDevframe } from 'devframe/initiate' +import { createRpcClient } from 'devframe/rpc/client' +import { createSseRpcChannel } from 'devframe/rpc/transports/sse-client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' + +/** + * Cross-runtime smoke test for the RPC transport binding — the regression + * guard for issue #317, where `instance-shell` always loaded crossws's Node + * adapter and crashed the WebSocket transport on Bun/Deno. + * + * It boots real `initDevframe` instances under whatever runtime executes it + * (Node, Bun, or Deno) and drives one RPC round-trip through each binding: + * + * - a **side-car** instance, where devframe owns a dedicated server — a real + * native WebSocket on Bun/Deno (crossws's Bun/Deno adapter over + * `Bun.serve` / `Deno.serve`), the Node adapter on Node; + * - a **shared-server** instance, where a foreign `node:http` server is + * handed in — a native WebSocket on Node, and the SSE fallback on Bun/Deno + * (which can't re-host a foreign `node:http` server natively). + * + * Run it with `bun tests/runtime/smoke.ts`, `deno run -A --node-modules-dir + * tests/runtime/smoke.ts`, or `tsx tests/runtime/smoke.ts`. The package must be + * built first — it imports the published `devframe/*` entry points. + */ + +type Runtime = 'node' | 'bun' | 'deno' + +function detectRuntime(): Runtime { + const g = globalThis as { Deno?: unknown, Bun?: unknown } + if (typeof g.Deno !== 'undefined') + return 'deno' + if (typeof g.Bun !== 'undefined') + return 'bun' + return 'node' +} + +function defineSmokeDefinition(): DevframeDefinition { + return { + id: 'runtime-smoke', + name: 'Runtime Smoke', + version: '0.0.0', + packageName: 'runtime-smoke', + homepage: 'https://example.test', + description: 'Cross-runtime RPC transport smoke test.', + setup(ctx) { + ctx.rpc.register({ + name: 'runtime-smoke:ping', + type: 'query', + jsonSerializable: true, + handler: () => 'pong', + }) + }, + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) + throw new Error(message) +} + +/** The side-car binding: a real native WebSocket on Bun/Deno, Node adapter on Node. */ +async function checkSidecar(runtime: Runtime): Promise { + const instance = initDevframe(defineSmokeDefinition(), { + base: '/', + auth: false, + host: '127.0.0.1', + ws: { sidecar: true }, + register: false, + }) + try { + await instance.ready + const meta = instance.connectionMeta() + assert(meta.backend === 'websocket', `side-car backend should be websocket, got "${meta.backend}"`) + const ws = meta.websocket + assert(ws && typeof ws === 'object' && 'port' in ws && ws.port, 'side-car meta should advertise a port') + const url = `ws://127.0.0.1:${(ws as { port: number }).port}/__ws` + + const channel = createWsRpcChannel({ url }) + const client = createRpcClient<{ 'runtime-smoke:ping': () => string }, Record>({}, { channel }) + try { + const result = await (client as { $call: (name: string) => Promise }).$call('runtime-smoke:ping') + assert(result === 'pong', `side-car RPC round-trip should return "pong", got ${JSON.stringify(result)}`) + } + finally { + channel.close() + } + console.log(` ✓ side-car: native WebSocket round-trip (${runtime})`) + } + finally { + await instance.close() + } +} + +/** The shared-server binding: native WebSocket on Node, SSE fallback on Bun/Deno. */ +async function checkSharedServer(runtime: Runtime): Promise { + // A foreign `node:http` server selects the `server` tier. On Bun/Deno the + // transport falls back to SSE (mounted on the shell's own app), so the + // server itself never has to listen — the SSE round-trip below rides + // `instance.handler` directly. + const server = createServer() + const instance = initDevframe(defineSmokeDefinition(), { + base: '/', + auth: false, + host: '127.0.0.1', + server, + register: false, + }) + try { + await instance.ready + const meta = instance.connectionMeta() + const expected = runtime === 'node' ? 'websocket' : 'sse' + assert( + meta.backend === expected, + `shared-server backend on ${runtime} should be "${expected}", got "${meta.backend}"`, + ) + assert( + meta.sse && (typeof meta.sse === 'string' || Boolean(meta.sse.path)), + 'shared-server meta should always advertise an SSE endpoint', + ) + + const channel = createSseRpcChannel({ + url: 'http://127.0.0.1/__sse', + fetch: (input, init) => instance.handler(new Request(input as string, init as RequestInit)), + }) + const client = createRpcClient<{ 'runtime-smoke:ping': () => string }, Record>({}, { channel }) + try { + const result = await (client as { $call: (name: string) => Promise }).$call('runtime-smoke:ping') + assert(result === 'pong', `shared-server SSE round-trip should return "pong", got ${JSON.stringify(result)}`) + } + finally { + channel.close() + } + console.log(` ✓ shared-server: ${expected.toUpperCase()} round-trip (${runtime})`) + } + finally { + await instance.close() + server.close() + } +} + +async function main(): Promise { + const runtime = detectRuntime() + console.log(`devframe RPC transport smoke test — runtime: ${runtime}`) + await checkSidecar(runtime) + await checkSharedServer(runtime) + console.log('all runtime smoke checks passed') +} + +main().catch((error) => { + console.error('runtime smoke test failed:') + console.error(error) + process.exit(1) +}) diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index d22d029a..1915953e 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -349,6 +349,18 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump."; }; + readonly DF0075: { + readonly why: (p: { + runtime: string; + }) => string; + readonly fix: "Keep the SSE endpoint enabled (drop `sse: false`) so clients connect over it on Bun/Deno, or move the socket to a side-car (`ws: { sidecar: true }`) which binds the native WebSocket adapter on its own port."; + }; + readonly DF0076: { + readonly why: (p: { + runtime: string; + }) => string; + readonly fix: "On Bun/Deno, serve the advertised `__ws` route from `Bun.serve` / `Deno.serve` with `attachBunWsTransport` / `attachDenoWsTransport` (see the hub-deno-minimal example), or connect over the SSE endpoint instead."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void]>;