diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index e1317f2e..a9df322a 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -1,5 +1,10 @@ -import type { DevframeDefinition, DevframeDeploymentKind } from '../types/devframe' -import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash } from 'ufo' +import type { ConnectionMeta } from '../types/context' +import type { DevframeDefinition, DevframeDeploymentKind, McpRouteOptions } from '../types/devframe' +import { getPort } from 'get-port-please' +import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo' +import { DEVFRAME_MCP_ROUTE } from '../constants' + +const DEFAULT_PORT = 9999 /** * Resolve the mount base path for a devframe's SPA. Hosted adapters @@ -18,3 +23,72 @@ export function resolveBasePath(def: DevframeDefinition, kind: DevframeDeploymen export function normalizeBasePath(base: string): string { return cleanDoubleSlashes(withTrailingSlash(withLeadingSlash(base))) } + +export interface ResolveDevServerPortOptions { + /** Bind host (passed to `get-port-please` for in-use detection). */ + host?: string + /** Override the preferred port. Default: `def.cli?.port ?? 9999`. */ + defaultPort?: number +} + +/** + * Resolve the listening port for `createDevServer` (and `createHandler`'s + * side-car tiers), honoring the definition's `cli.port` / `cli.portRange` / + * `cli.random` settings. Exposed separately so authors who run their own + * argv parsing can resolve a port up-front (to print it, log it, etc.) + * before starting the server. + */ +export async function resolveDevServerPort( + def: DevframeDefinition, + options: ResolveDevServerPortOptions = {}, +): Promise { + const host = options.host ?? def.cli?.host ?? 'localhost' + const port = options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT + // Only include optional fields when set — `get-port-please` spreads + // user options over its defaults, so `portRange: undefined` would + // wipe out the internal `[]` and crash on iteration. + const portOptions: Parameters[0] = { port, host } + if (def.cli?.portRange) + portOptions.portRange = def.cli.portRange + if (def.cli?.random) + portOptions.random = def.cli.random + return getPort(portOptions) +} + +/** + * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into + * concrete options, or `undefined` when the MCP route is disabled. + */ +export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { + if (!mcp) + return undefined + return mcp === true ? {} : mcp +} + +/** + * Resolve the `mcp` entry a `__connection.json` should advertise for a dev + * server started with the given `mcp` option (falling back to `def.cli?.mcp`, + * exactly like `createDevServer`), or `undefined` when the route is + * disabled. + * + * Hosted bridges that hand-roll their connection meta pass the side-car + * `port`: the advertised path becomes absolute (the side-car mounts at `/`) + * and the client dials `:`. Without `port` the path + * stays relative, resolved against `__connection.json`'s own location (the + * same-server default). + * + * @experimental + */ +export function resolveMcpConnectionMeta( + def: DevframeDefinition, + mcp: boolean | McpRouteOptions | undefined, + port?: number, +): ConnectionMeta['mcp'] { + const config = resolveMcpConfig(mcp ?? def.cli?.mcp) + if (!config) + return undefined + const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE) + return port != null + ? { path: withLeadingSlash(route), port } + : { path: route } +} diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index f1e1c2f2..36730ab2 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,27 +1,22 @@ import type { Peer } from 'crossws' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/server' -import type { ConnectionMeta } from '../types/context' -import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' +import { createServer } from 'node:http' import process from 'node:process' import { open } from 'devframe/utils/open' -import { mountStaticHandler } from 'devframe/utils/serve-static' -import { getPort } from 'get-port-please' -import { H3 } from 'h3' -import { resolve } from 'pathe' -import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from 'ufo' -import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' -import { createHostContext } from '../node/context' +import { H3, toNodeHandler } from 'h3' +import { joinURL, withBase, withoutLeadingSlash } from 'ufo' +import { DEVFRAME_MCP_ROUTE } from '../constants' import { diagnostics } from '../node/diagnostics' -import { createH3DevframeHost } from '../node/host-h3' import { registerDevframeInstance } from '../node/instance-registry' -import { startHttpAndWs } from '../node/server' import { normalizeHttpServerUrl } from '../node/utils' -import { createInteractiveAuth } from '../recipes/interactive-auth' -import { normalizeBasePath, resolveBasePath } from './_shared' +import { normalizeBasePath, resolveBasePath, resolveDevServerPort, resolveMcpConfig } from './_shared' +import { getInstanceInternals, initDevframe } from './initiate' -const DEFAULT_PORT = 9999 +export { resolveDevServerPort, resolveMcpConnectionMeta } from './_shared' +export type { ResolveDevServerPortOptions } from './_shared' export interface CreateDevServerOptions { /** Bind host. Default: `def.cli?.host ?? 'localhost'`. */ @@ -107,37 +102,6 @@ export interface CreateDevServerOptions { onReady?: (info: { origin: string, port: number, app: H3 }) => void | Promise } -export interface ResolveDevServerPortOptions { - /** Bind host (passed to `get-port-please` for in-use detection). */ - host?: string - /** Override the preferred port. Default: `def.cli?.port ?? 9999`. */ - defaultPort?: number -} - -/** - * Resolve the listening port for {@link createDevServer}, honoring the - * definition's `cli.port` / `cli.portRange` / `cli.random` settings. - * Exposed separately so authors who run their own argv parsing can - * resolve a port up-front (to print it, log it, etc.) before starting - * the server. - */ -export async function resolveDevServerPort( - def: DevframeDefinition, - options: ResolveDevServerPortOptions = {}, -): Promise { - const host = options.host ?? def.cli?.host ?? 'localhost' - const port = options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT - // Only include optional fields when set — `get-port-please` spreads - // user options over its defaults, so `portRange: undefined` would - // wipe out the internal `[]` and crash on iteration. - const portOptions: Parameters[0] = { port, host } - if (def.cli?.portRange) - portOptions.portRange = def.cli.portRange - if (def.cli?.random) - portOptions.random = def.cli.random - return getPort(portOptions) -} - /** * Start a devframe dev server for a {@link DevframeDefinition} — * h3 + WebSocket RPC + (optionally) the author's SPA mounted at the @@ -160,132 +124,90 @@ export async function createDevServer( def: DevframeDefinition, options: CreateDevServerOptions = {}, ): Promise { - const distDir = options.distDir ?? def.cli?.distDir - const host = options.host ?? def.cli?.host ?? 'localhost' - const port = options.port ?? await resolveDevServerPort(def, { host }) + const requestedPort = options.port ?? await resolveDevServerPort(def, { host }) const flags = options.flags ?? {} const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone') const app = options.app ?? new H3() + + // The dev server is `initDevframe` plus a node listener: the instance owns + // the whole surface (setup, MCP, meta, SPA, WS binding, auth), mounted on + // this server's app; listening starts first so the shared WS tier — and + // the advertised origin — reflect the real bound port (`port: 0` binds an + // ephemeral one). + const server = createServer(toNodeHandler(app)) + try { + await new Promise((resolveListen, rejectListen) => { + const onError = (error: Error): void => rejectListen(error) + // Without this listener a failed bind emits `error` with nobody + // attached — an uncaughtException — and the `listen` callback never + // fires, so this promise never settles. + server.once('error', onError) + server.listen(requestedPort, host, () => { + server.removeListener('error', onError) + resolveListen() + }) + }) + } + catch (error) { + throw diagnostics.DF0052({ + host, + port: requestedPort, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + const address = server.address() + const port = typeof address === 'object' && address ? address.port : requestedPort // A wildcard bind host (`0.0.0.0` / `::`) isn't dialable from a browser, so // advertise a loopback origin for anything that hands a client an absolute URL. const origin = normalizeHttpServerUrl(host, port) - const h3Host = createH3DevframeHost({ + const devframe = initDevframe(def, { + base: basePath, + distDir: options.distDir, + app, + server, + host, origin, - appName: def.id, - mount: (base, dir) => { - mountStaticHandler(app, base, dir) - }, - }) - - const ctx = await createHostContext({ - cwd: process.cwd(), - mode: 'dev', - host: h3Host, + ws: options.ws, + // The `--no-auth` flag forces the gate off regardless of the `auth` + // option / definition default (which the instance resolves itself). + auth: flags.auth === false ? false : options.auth, + mcp: options.mcp, + flags, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, + // This server is devframe's own — nothing else handles its upgrades, so + // off-route upgrade attempts are rejected promptly. + destroyUnmatchedUpgrades: true, }) - const setupInfo: DevframeSetupInfo = { flags } - await def.setup(ctx, setupInfo) - // Route-based MCP server (opt-in via `cli.mcp` / the `mcp` option). Mounted - // before the SPA static catch-all so the exact `/__mcp` route wins, and - // advertised in `__connection.json` so in-browser tooling can discover it. - // The MCP SDK is an optional peer dep, so its code is only pulled in - // (dynamically) when the route is enabled. - const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) - let mcpDispose: (() => Promise) | undefined - let mcpMeta: ConnectionMeta['mcp'] - if (mcpConfig) { - const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) - const mcpPath = joinURL(basePath, mcpRoute) - let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp - try { - ;({ mountMcpHttp } = await import('./mcp/http')) - } - catch (error) { - const reason = error instanceof Error ? error.message : String(error) - throw diagnostics.DF0017({ transport: 'http', reason, cause: error }) - } - const mounted = mountMcpHttp(app, ctx, mcpPath, { - serverName: `${def.id} (devframe)`, - serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, - allowedOrigins: mcpConfig.allowedOrigins, - }) - mcpDispose = mounted.dispose - mcpMeta = { path: mcpRoute } + try { + await devframe.ready + } + catch (error) { + await new Promise(resolveClose => server.close(() => resolveClose())) + throw error } - // Connection meta — the SPA fetches this to discover the RPC backend. How - // the WS endpoint is bound and advertised follows the resolved ws config: - // a same-origin route (default, proxy-safe), a dedicated port, or a remote - // origin. Both files sit at the SPA root so the deployed SPA discovers them - // via relative `./__connection.json` / `./` fetches. - const { bindPath, wsPort, meta } = resolveWsConnection(def, options, basePath) - const connectionMetaPath = joinURL(basePath, DEVFRAME_CONNECTION_META_FILENAME) - app.use(connectionMetaPath, () => ({ - backend: 'websocket', - websocket: meta, - ...(mcpMeta ? { mcp: mcpMeta } : {}), - })) - - if (distDir) - mountStaticHandler(app, basePath, resolve(distDir)) + const internals = getInstanceInternals(devframe) + // Every dev-server configuration binds a local transport (`server` is + // always passed), so the startHttpAndWs handle is always present. + const transport = internals.started! - // Resolve authentication. The standalone dev server gates by default: when - // the author leaves `auth` unset (or `true`), auto-wire devframe's - // interactive OTP handler and print its code + magic-link banner once the - // server is listening (a gate is useless without surfacing the code). A - // `false` (including the `--no-auth` flag) opts out; a handler object is - // passed straight through to `startHttpAndWs`. An explicit `options.auth` - // wins over the definition default — a hosted adapter (e.g. `viteDevBridge`) - // passes `false` so the plugin's own gate never fires and the host owns auth - // — but the `--no-auth` flag can still force the gate off. - const authOption = flags.auth === false - ? false - : options.auth !== undefined - ? options.auth - : def.cli?.auth - let authHandler: DevframeAuthHandler | undefined - let resolvedAuth: boolean | DevframeAuthHandler - if (authOption === false) { - resolvedAuth = false - } - else if (typeof authOption === 'object') { - authHandler = authOption - resolvedAuth = authOption - } - else { - authHandler = createInteractiveAuth(ctx) - resolvedAuth = authHandler - } + await options.onReady?.({ origin, port, app }) + await maybeOpenBrowser(def, flags, `${origin}${basePath}`, options.openBrowser, internals.authHandler) - const started = await startHttpAndWs({ - context: ctx, - host, - port, - app, - path: bindPath, - wsPort, - auth: resolvedAuth, - onPeerConnect: options.onPeerConnect, - onPeerDisconnect: options.onPeerDisconnect, - onReady: async (info) => { - // Print the auth banner before the caller's own onReady / browser open - // so the code is on screen by the time a browser lands on the page. - authHandler?.printBanner() - await options.onReady?.(info) - await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler) - }, - }) + const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) // Record the instance in the global registry so discovery tooling // (`devframe connect`) finds it without port guessing. Registration never // throws; a crash-orphaned record is pruned by readers on a failed probe. const registration = registerDevframeInstance({ pid: process.pid, - port: started.port, - origin: normalizeHttpServerUrl(host, started.port), + port, + origin, basePath, id: def.id, name: def.name, @@ -294,84 +216,22 @@ export async function createDevServer( startedAt: Date.now(), }) - // Fold MCP session teardown and registry removal into the server's close so - // callers get a single graceful-shutdown handle. - const closeServer = started.close - started.close = async () => { - registration.unregister() - await mcpDispose?.() - await closeServer() + return { + origin, + port, + app, + ws: transport.ws, + rpcGroup: transport.rpcGroup, + connectionMeta: transport.connectionMeta, + async close() { + registration.unregister() + // Instance teardown detaches the WS transport (and closes any + // dedicated-port socket server) and disposes MCP sessions; the HTTP + // server is this function's own to close. + await devframe.close() + await new Promise(resolveClose => server.close(() => resolveClose())) + }, } - - return started -} - -/** - * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into - * concrete options, or `undefined` when the MCP route is disabled. - */ -function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { - if (!mcp) - return undefined - return mcp === true ? {} : mcp -} - -/** - * Resolve the `mcp` entry a `__connection.json` should advertise for a dev - * server started with the given `mcp` option (falling back to `def.cli?.mcp`, - * exactly like {@link createDevServer}), or `undefined` when the route is - * disabled. - * - * Hosted bridges that hand-roll their connection meta (`viteDevBridge`, - * `@devframes/next`'s handler) pass the side-car `port`: the advertised path - * becomes absolute (the side-car mounts at `/`) and the client dials - * `:`. Without `port` the path stays relative, resolved - * against `__connection.json`'s own location (the same-server default). - * - * @experimental - */ -export function resolveMcpConnectionMeta( - def: DevframeDefinition, - mcp: boolean | McpRouteOptions | undefined, - port?: number, -): ConnectionMeta['mcp'] { - const config = resolveMcpConfig(mcp ?? def.cli?.mcp) - if (!config) - return undefined - const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE) - return port != null - ? { path: withLeadingSlash(route), port } - : { path: route } -} - -/** - * Resolve the three WS connection scenarios from the definition / call-site - * config into a concrete server bind path, optional dedicated port, and the - * `__connection.json` descriptor the browser resolves. - */ -function resolveWsConnection( - def: DevframeDefinition, - options: CreateDevServerOptions, - basePath: string, -): { bindPath: string, wsPort: number | undefined, meta: ConnectionMeta['websocket'] } { - const ws = options.ws ?? def.cli?.ws ?? {} - // Normalize the route to a bare segment; the meta carries it relative so the - // client resolves it against its own origin (proxy-safe). - const route = withoutLeadingSlash(ws.route ?? DEVFRAME_WS_ROUTE) - - // (3) Remote origin — host the socket locally on the shared route, but tell - // the browser to dial the fully-qualified endpoint (a tunnel/relay) verbatim. - if (ws.url) - return { bindPath: joinURL(basePath, route), wsPort: undefined, meta: ws.url } - - // (2) Different port — a standalone socket server on its own port, rooted at - // `/`. The client targets `ws(s)://:/`. - if (ws.port != null) - return { bindPath: withLeadingSlash(route), wsPort: ws.port, meta: { port: ws.port, path: route } } - - // (1) Same server, different route (default) — share the HTTP port; advertise - // a relative same-origin path. - return { bindPath: joinURL(basePath, route), wsPort: undefined, meta: { path: route } } } async function maybeOpenBrowser( diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index bfc300c5..6d4de4de 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -1,5 +1,6 @@ +import type { Peer } from 'crossws' import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' -import type { ConnectionMeta, DevframeNodeContext } from 'devframe/types' +import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeStorageScope } from 'devframe/types' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/server' @@ -27,11 +28,12 @@ export interface InitDevframeOptions { */ base?: string /** - * Override `def.cli?.distDir`. When neither is set the handler runs in - * **bridge mode** — only `__connection.json`, the WS endpoint, and the MCP + * Override `def.cli?.distDir`. When neither is set — or `false` is passed + * to suppress the definition's own `distDir` — the handler runs in + * **bridge mode**: only `__connection.json`, the WS endpoint, and the MCP * route (when enabled) are served; the SPA is hosted elsewhere. */ - distDir?: string + distDir?: string | false /** * Share the host's `node:http` server for the WebSocket RPC endpoint: the * upgrade listener binds to `__ws` on this server, so no extra port @@ -87,11 +89,13 @@ export interface InitDevframeOptions { */ key?: string /** - * Public origin the host app is reachable at (e.g. `http://localhost:3000`). - * When omitted, it is derived lazily from the first request the handler - * serves — used for the auth banner's magic link and absolute dock URLs. + * Public origin the host app is reachable at (e.g. `http://localhost:3000`), + * or a getter for hosts that resolve it late. When omitted (or the getter + * returns a falsy value), it is derived lazily from the first request the + * handler serves — used for the auth banner's magic link and absolute dock + * URLs. */ - origin?: string + origin?: string | (() => string) /** Parsed flag bag forwarded to `def.setup(ctx, { flags })`. */ flags?: Record /** @@ -101,6 +105,38 @@ export interface InitDevframeOptions { * recommended). Default: loopback-only. */ allowedOrigins?: readonly string[] | WsOriginRegistry | false + /** + * h3 app to mount the handler's routes on. When omitted a fresh internal + * app is created — the common middleware case. An adapter that owns the + * whole server (e.g. `createDevServer`) passes its own app so callers can + * compose custom routes ahead of devframe's. + */ + app?: H3 + /** + * Override where persisted devframe state lives, per + * `DevframeHost.getStorageDir`. Defaults to the standalone host layout + * (`.devframe/`, `node_modules/./devframe/`, `~/./devframe/`). + */ + getStorageDir?: (scope: DevframeStorageScope) => string + /** + * Destroy upgrade requests on a shared `server` that don't match the WS + * route, instead of leaving them for the host's own upgrade handlers. + * Enable when devframe's adapter owns the server outright (nothing else + * handles its upgrades) so off-route clients are rejected promptly. + * Default: `false` (coexist-friendly). + */ + destroyUnmatchedUpgrades?: boolean + /** + * Called once per new WS connection, right after its session is created. + * Forwarded verbatim to the underlying transport (see + * `StartHttpAndWsOptions.onPeerConnect`). + */ + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void + /** + * Called once per closed WS connection, right after the transport's own + * disconnect bookkeeping runs (see `StartHttpAndWsOptions.onPeerDisconnect`). + */ + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void } /** @@ -180,12 +216,15 @@ function optionsHash(def: DevframeDefinition, options: InitDevframeOptions): str base: options.base, distDir: options.distDir, host: options.host, - origin: options.origin, + origin: typeof options.origin === 'function' ? 'getter' : options.origin, ws: options.ws, server: options.server != null, + app: options.app != null, auth: typeof options.auth === 'object' ? 'custom' : options.auth, mcp: options.mcp, allowedOrigins: Array.isArray(options.allowedOrigins) ? options.allowedOrigins : typeof options.allowedOrigins, + getStorageDir: options.getStorageDir != null, + destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, cwd: process.cwd(), }) } @@ -195,6 +234,27 @@ function samePath(a: string, b: string): boolean { return withoutTrailingSlash(a) === withoutTrailingSlash(b) } +/** + * Live internals of a handler, for the first-party adapters built on it + * (`createDevServer` exposes the transport's `ws`/`rpcGroup` through its + * `StartedServer` contract). + * + * @internal + */ +export interface DevframeInstanceInternals { + /** The `startHttpAndWs` handle backing the side-car / shared-server WS tiers. */ + readonly started?: StartedServer + /** The resolved auth handler when the gate is active. */ + readonly authHandler?: DevframeAuthHandler +} + +const INSTANCE_INTERNALS = new WeakMap() + +/** @internal */ +export function getInstanceInternals(handler: object): DevframeInstanceInternals { + return INSTANCE_INTERNALS.get(handler) ?? {} +} + /** * Serve a devframe through one framework-agnostic, web-standard handler — * the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the @@ -203,11 +263,13 @@ function samePath(a: string, b: string): boolean { * a connect stack) and the devframe is live inside that app. * * The factory is synchronous and kicks off initialization eagerly; - * `handler`/`nodeMiddleware` await readiness internally. How the WebSocket is - * bound resolves in precedence order — `ws.url` (external, advertise-only) - * > `ws.port` (explicit side-car) > `server` (shared upgrade at - * `__ws`) > Bun fetch-upgrade (under Bun) > an eager side-car on a - * free port — and `__connection.json` reflects whichever tier is active. + * `handler`/`nodeMiddleware` await readiness internally. The WebSocket + * binding resolves in precedence order — `ws.port` (explicit side-car) > + * `server` (shared upgrade at `__ws`) > `ws.url` alone (no local + * transport; an external server owns it) > Bun fetch-upgrade (under Bun) > + * an eager side-car on a free port — while `ws.url`, when set, always + * overrides the *advertised* endpoint (the tunnel pattern). + * `__connection.json` reflects whichever combination is active. */ export function initDevframe( def: DevframeDefinition, @@ -236,23 +298,27 @@ function instantiateDevframe( ): DevframeInstance { const base = options.base ? normalizeBasePath(options.base) : resolveBasePath(def, 'hosted') const baseNoSlash = withoutTrailingSlash(base) - const distDir = options.distDir ?? def.cli?.distDir - const app = new H3() + const distDir = options.distDir === false ? undefined : options.distDir ?? def.cli?.distDir + const app = options.app ?? new H3() // The public origin is often unknowable at creation (the host app owns the // listener) — derive it from the first request and let the auth banner - // wait for it, unless the caller pinned one. - let resolvedOrigin: string | undefined = options.origin + // wait for it, unless the caller pinned one (as a string or a getter). + let derivedOrigin: string | undefined + function currentOrigin(): string | undefined { + const explicit = typeof options.origin === 'function' ? options.origin() : options.origin + return explicit || derivedOrigin + } let authHandler: DevframeAuthHandler | undefined let bannerPrinted = false function maybePrintBanner(): void { - if (bannerPrinted || !authHandler || !resolvedOrigin) + if (bannerPrinted || !authHandler || !currentOrigin()) return bannerPrinted = true authHandler.printBanner() } function noteOrigin(origin: string): void { - resolvedOrigin ??= origin + derivedOrigin ??= origin maybePrintBanner() } @@ -264,13 +330,16 @@ function instantiateDevframe( let ctx: DevframeNodeContext async function init(): Promise { - const host = createH3DevframeHost({ - origin: () => resolvedOrigin ?? 'http://localhost', + const h3Host = createH3DevframeHost({ + origin: () => currentOrigin() ?? 'http://localhost', appName: def.id, mount: (mountBase, dir) => { mountStaticHandler(app, mountBase, dir) }, }) + const host = options.getStorageDir + ? { ...h3Host, getStorageDir: options.getStorageDir } + : h3Host ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', @@ -341,6 +410,8 @@ function instantiateDevframe( path: withLeadingSlash(route), auth: resolvedAuth, allowedOrigins: options.allowedOrigins, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, }) websocketMeta = { port: started.port, path: route } } @@ -354,6 +425,9 @@ function instantiateDevframe( path: joinURL(base, route), auth: resolvedAuth, allowedOrigins: options.allowedOrigins, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, + destroyUnmatched: options.destroyUnmatchedUpgrades, }) websocketMeta = { path: route } } @@ -374,6 +448,8 @@ function instantiateDevframe( path: withLeadingSlash(route), auth: resolvedAuth, allowedOrigins: options.allowedOrigins, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, }) websocketMeta = { port: started.port, path: route } } @@ -382,7 +458,12 @@ function instantiateDevframe( // `handler(request, server)`, hooks exposed via `websocket`. const { attachBunWsTransport } = await import('./initiate-bun') const { createContextRpcServer } = await import('../node/rpc-core') - const core = createContextRpcServer({ context: ctx, auth: resolvedAuth }) + const core = createContextRpcServer({ + context: ctx, + auth: resolvedAuth, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, + }) bunTier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins }) bunUpgradePath = joinURL(base, route) websocketMeta = { path: route } @@ -507,5 +588,13 @@ function instantiateDevframe( await bunTier?.close() }, } + INSTANCE_INTERNALS.set(handler, { + get started() { + return started + }, + get authHandler() { + return authHandler + }, + }) return handler } diff --git a/packages/devframe/src/helpers/__tests__/vite.test.ts b/packages/devframe/src/helpers/__tests__/vite.test.ts index 1ca5fed2..fb040fe9 100644 --- a/packages/devframe/src/helpers/__tests__/vite.test.ts +++ b/packages/devframe/src/helpers/__tests__/vite.test.ts @@ -1,4 +1,7 @@ +import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { DevframeDefinition } from '../../types/devframe' +import type { DevframeViteDevServerLike } from '../vite' +import { createServer } from 'node:http' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' @@ -25,63 +28,95 @@ function defineTestDef(): DevframeDefinition { } } -interface FakeViteServer { - middlewares: { use: (path: string, handler: any) => void } - httpServer: null - routes: Map +type ConnectMiddleware = (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void + +/** + * A minimal stand-in for Vite's dev server: a real node http server whose + * request handling walks the registered connect middlewares in order — + * enough to exercise both `use(fn)` and `use(path, fn)` registrations and + * the shared-`httpServer` WS tier. + */ +interface FakeViteServer extends DevframeViteDevServerLike { + httpServer: NodeHttpServer + listen: (port: number, host: string) => Promise + close: () => void } function fakeViteServer(): FakeViteServer { - const routes = new Map() + const stack: Array<{ path?: string, handler: ConnectMiddleware }> = [] + const httpServer = createServer((req, res) => { + let i = 0 + const next = (): void => { + const entry = stack[i++] + if (!entry) { + res.statusCode = 404 + res.end() + return + } + if (entry.path && !(req.url ?? '/').startsWith(entry.path)) { + next() + return + } + entry.handler(req, res, next) + } + next() + }) return { - middlewares: { use: (path, handler) => routes.set(path, handler) }, - httpServer: null, - routes, + middlewares: { + use: ((pathOrHandler: string | ConnectMiddleware, maybeHandler?: ConnectMiddleware) => { + if (typeof pathOrHandler === 'string') + stack.push({ path: pathOrHandler, handler: maybeHandler! }) + else + stack.push({ handler: pathOrHandler }) + }) as DevframeViteDevServerLike['middlewares']['use'], + }, + httpServer, + listen: (port, host) => new Promise(resolve => httpServer.listen(port, host, resolve)), + close: () => { + httpServer.close() + httpServer.closeAllConnections() + }, } } -/** Invoke a registered connect-style middleware and capture its JSON body. */ -async function readJsonMiddleware(handler: any): Promise { - return await new Promise((resolvePromise) => { - handler(undefined, { - setHeader: () => {}, - end: (body: string) => resolvePromise(JSON.parse(body)), - }) - }) -} - describe('viteDevBridge (bridge mode mcp)', () => { let bridge: ReturnType | undefined + let vite: FakeViteServer | undefined afterEach(async () => { await bridge?.closeBundle?.() bridge = undefined + vite?.close() + vite = undefined }) - it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => { - const port = await getPort({ port: 19710, host: '127.0.0.1' }) + it('serves discovery + MCP through the Vite middleware; pinned port advertises the side-car', async () => { + const host = '127.0.0.1' + const vitePort = await getPort({ port: 19705, host }) + const wsPort = await getPort({ port: 19710, host }) bridge = viteDevBridge(defineTestDef(), { - devMiddleware: { port, host: '127.0.0.1' }, + devMiddleware: { port: wsPort, host }, mcp: true, - // The bridge now gates by default; opt out here so this test can dial - // the WS/MCP side-car directly. + // The bridge gates by default; opt out here so this test can dial + // the WS side-car and MCP route directly. auth: false, }) - const server = fakeViteServer() - await bridge.configureServer(server) + vite = fakeViteServer() + await vite.listen(vitePort, host) + await bridge.configureServer(vite) - const metaHandler = server.routes.get('/__vite-bridge-test/__connection.json') - expect(metaHandler).toBeDefined() - const meta = await readJsonMiddleware(metaHandler) + const meta = await (await fetch(`http://${host}:${vitePort}/__vite-bridge-test/__connection.json`)).json() expect(meta.backend).toBe('websocket') - expect(meta.websocket).toEqual({ port, path: '/__ws' }) - expect(meta.mcp).toEqual({ port, path: '/__mcp' }) + // Pinned port → explicit side-car; the route rides the unified `__ws`. + expect(meta.websocket).toEqual({ port: wsPort, path: '__ws' }) + // MCP lives on the Vite origin itself now (same-origin relative path). + expect(meta.mcp).toEqual({ path: '__mcp' }) // The advertised endpoint is live: a real MCP client presenting a loopback // Origin (required by the route's gate) can connect and list agent tools. - const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`), { - requestInit: { headers: { origin: `http://127.0.0.1:${port}` } }, + const transport = new StreamableHTTPClientTransport(new URL(`http://${host}:${vitePort}/__vite-bridge-test/__mcp`), { + requestInit: { headers: { origin: `http://${host}:${vitePort}` } }, }) const client = new Client({ name: 'test-client', version: '0.0.0' }) try { @@ -94,27 +129,45 @@ describe('viteDevBridge (bridge mode mcp)', () => { } }) - it('omits the mcp block when the option is not set', async () => { - const port = await getPort({ port: 19720, host: '127.0.0.1' }) + it('shares the Vite http server for the WS endpoint when no port is pinned', async () => { + const host = '127.0.0.1' + const vitePort = await getPort({ port: 19715, host }) bridge = viteDevBridge(defineTestDef(), { - devMiddleware: { port, host: '127.0.0.1' }, + devMiddleware: { host }, auth: false, }) - const server = fakeViteServer() - await bridge.configureServer(server) + vite = fakeViteServer() + await vite.listen(vitePort, host) + await bridge.configureServer(vite) - const meta = await readJsonMiddleware(server.routes.get('/__vite-bridge-test/__connection.json')) + const meta = await (await fetch(`http://${host}:${vitePort}/__vite-bridge-test/__connection.json`)).json() + // Zero extra ports: a same-origin relative route on Vite's own server. + expect(meta.websocket).toEqual({ path: '__ws' }) expect(meta.mcp).toBeUndefined() + + const rpc = createRpcClient({}, { + channel: createWsRpcChannel({ url: `ws://${host}:${vitePort}/__vite-bridge-test/__ws` }), + }) + try { + const res = await rpc.$call('anonymous:devframe:auth', { authToken: '', ua: 'test', origin: 'http://localhost' }) as { isTrusted: boolean } + expect(res.isTrusted).toBe(true) + } + finally { + rpc.$close?.() + } }) }) describe('viteDevBridge (auth default)', () => { let bridge: ReturnType | undefined + let vite: FakeViteServer | undefined afterEach(async () => { await bridge?.closeBundle?.() bridge = undefined + vite?.close() + vite = undefined }) /** Handshake result on a fresh, unauthenticated WS connection. */ @@ -134,7 +187,8 @@ describe('viteDevBridge (auth default)', () => { it('gates the side-car by default (unset auth → untrusted handshake)', async () => { const port = await getPort({ port: 19730, host: '127.0.0.1' }) bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' } }) - await bridge.configureServer(fakeViteServer()) + vite = fakeViteServer() + await bridge.configureServer(vite) // A gated server answers the handshake with `isTrusted: false` until a // code is exchanged; an ungated (`auth: false`) server auto-trusts. @@ -144,7 +198,8 @@ describe('viteDevBridge (auth default)', () => { it('opts out when auth: false is passed explicitly (auto-trust handshake)', async () => { const port = await getPort({ port: 19740, host: '127.0.0.1' }) bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' }, auth: false }) - await bridge.configureServer(fakeViteServer()) + vite = fakeViteServer() + await bridge.configureServer(vite) expect(await handshakeIsTrusted(port)).toBe(true) }) diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index ab951093..ac2a7fa4 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -1,10 +1,11 @@ +import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' +import type { DevframeInstance } from '../adapters/initiate' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeDefinition, McpRouteOptions } from '../types/devframe' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' import { normalizeBasePath, resolveBasePath } from '../adapters/_shared' -import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from '../adapters/dev' -import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' +import { initDevframe } from '../adapters/initiate' import { diagnostics } from '../node/diagnostics' export interface ViteDevBridgeOptions { @@ -19,30 +20,36 @@ export interface ViteDevBridgeOptions { base?: string /** * Dev-time middleware mode. When set, the host app owns the SPA and - * devframe spins up a separate RPC + WS server on a resolved port, - * registering Vite middleware at `__connection.json` so the - * host-served SPA can discover the WS endpoint. + * devframe serves the RPC surface through the Vite dev server itself — + * `__connection.json` for discovery and the WebSocket upgrade at + * `__ws` on Vite's own HTTP server (zero extra ports, proxy/HTTPS + * friendly). When Vite runs in middleware mode (no `httpServer`) — or a + * `port` is pinned — the socket falls back to a side-car server on its + * own port instead. * * - `false` (default) — static-mount the SPA at `base` with SPA * fallback. No RPC server is started. - * - `true` — bridge mode with all defaults (port from - * {@link resolveDevServerPort}, host from `def.cli?.host`). + * - `true` — bridge mode with all defaults. * - object — bridge mode with explicit overrides. */ devMiddleware?: boolean | { - /** Override the bridge port. Default: {@link resolveDevServerPort}. */ + /** + * Pin a side-car port for the RPC socket instead of sharing Vite's + * server. Default: share Vite's HTTP server (side-car only when Vite + * has none). + */ port?: number - /** Override the bridge bind host. Default: `def.cli?.host ?? 'localhost'`. */ + /** Override the side-car bind host. Default: `def.cli?.host ?? 'localhost'`. */ host?: string /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ flags?: Record } /** - * Whether the bridged devframe runs its own auth gate. The side-car RPC - * server is reachable by anything that can open its socket, so it **gates by - * default**: when unset, authentication resolves through `createDevServer` - * (devframe's interactive OTP gate unless the definition's `cli.auth` opts - * out), and the side-car prints its code/link banner to stdout. Pass a + * Whether the bridged devframe runs its own auth gate. The RPC endpoint is + * reachable by anything that can open its socket, so it **gates by + * default**: when unset, authentication resolves through devframe's + * interactive OTP gate (unless the definition's `cli.auth` opts out), and + * the bridge prints its code/link banner to stdout. Pass a * {@link DevframeAuthHandler} to install a custom scheme, or `false` to opt * out for a single-user localhost host that owns the trust boundary another * way. Only applies in bridge mode (`devMiddleware`); the static-mount mode @@ -52,27 +59,30 @@ export interface ViteDevBridgeOptions { */ auth?: boolean | DevframeAuthHandler /** - * Expose the side-car's route-based MCP server (Streamable-HTTP) and - * advertise it in the bridge's `__connection.json`. Forwarded to - * {@link createDevServer}: overrides `def.cli?.mcp`, `undefined` falls - * through to it, `false` disables the route regardless. Only applies in - * bridge mode (`devMiddleware`); the static-mount mode starts no server. - * - * The endpoint lives on the side-car's own port, so the advertised meta - * carries `{ port, path }` — see `ConnectionMeta['mcp']`. + * Expose the bridge's route-based MCP server (Streamable-HTTP) at + * `__mcp` — on the Vite app's own origin — and advertise it in the + * bridge's `__connection.json`. Overrides `def.cli?.mcp`, `undefined` + * falls through to it, `false` disables the route regardless. Only applies + * in bridge mode (`devMiddleware`); the static-mount mode starts no server. * * @experimental */ mcp?: boolean | McpRouteOptions } +/** The slice of a Vite dev server the bridge plugin touches. */ +export interface DevframeViteDevServerLike { + middlewares: { + use: ((path: string, handler: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void) => void) + & ((handler: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void) => void) + } + httpServer?: NodeHttpServer | null +} + export interface DevframeVitePlugin { name: string apply: 'serve' - configureServer: (server: { - middlewares: { use: (path: string, handler: any) => void } - httpServer?: { once: (event: 'close', cb: () => void) => void } | null - }) => void | Promise + configureServer: (server: DevframeViteDevServerLike) => void | Promise closeBundle?: () => void | Promise } @@ -84,16 +94,16 @@ export interface DevframeVitePlugin { * `options.base` with SPA fallback enabled. No RPC server is started. * * - **bridge mode** (`devMiddleware: true | {…}`) — skips the static - * mount; the host app owns the SPA. Devframe starts a separate - * RPC + WS dev server (via {@link createDevServer} in bridge mode) - * and registers Vite middleware at `__connection.json` so the - * host-served SPA can discover the WS endpoint via - * {@link connectDevframe}. + * mount; the host app owns the SPA. Devframe serves discovery + * (`__connection.json`), the WebSocket RPC upgrade + * (`__ws`, shared on Vite's own HTTP server), and the optional + * MCP route through {@link initDevframe}'s node middleware, so the + * host-served SPA can discover the endpoint via {@link connectDevframe}. * - * The side-car RPC server **gates by default** (devframe's interactive OTP - * unless the definition's `cli.auth` opts out), printing its code/link banner - * to stdout, so a bridged devframe isn't silently reachable by anything that - * can open its socket. Pass `options.auth: false` to opt out for a single-user + * The bridge **gates by default** (devframe's interactive OTP unless the + * definition's `cli.auth` opts out), printing its code/link banner to stdout, + * so a bridged devframe isn't silently reachable by anything that can open + * its socket. Pass `options.auth: false` to opt out for a single-user * localhost host, or a {@link DevframeAuthHandler} for a custom scheme. * * Use bridge mode when integrating with frameworks that own the SPA @@ -117,62 +127,57 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio } const mw = options.devMiddleware === true ? {} : options.devMiddleware - let started: Awaited> | undefined + let instance: DevframeInstance | undefined return { name: `devframe:${d.id}`, apply: 'serve', async configureServer(server) { // Vite re-invokes `configureServer` on each restart cycle; close - // the prior handle so we don't leak the WS server. Silent catch — + // the prior handle so we don't leak the WS transport. Silent catch — // a stale handle's close failure shouldn't block a fresh start. - await started?.close().catch(() => {}) - started = undefined + await instance?.close().catch(() => {}) + instance = undefined - let port: number try { - port = mw.port ?? await resolveDevServerPort(d, { host: mw.host }) - started = await createDevServer(d, { - host: mw.host, - port, + const created = initDevframe(d, { + base, + // The host app owns the SPA in bridge mode — never mount the + // definition's own distDir here. + distDir: false, flags: mw.flags, - openBrowser: false, - // Gate by default: an unset `auth` defers to `createDevServer` - // (devframe's interactive OTP unless `cli.auth` opts out) rather than - // leaving the side-car socket ungated. `false` opts out explicitly. + host: mw.host, + // Pinned port → explicit side-car. Otherwise share Vite's own + // HTTP server; a middleware-mode Vite (no httpServer) falls back + // to the handler's eager auto side-car. + ...(mw.port != null + ? { ws: { port: mw.port } } + : server.httpServer + ? { server: server.httpServer } + : {}), + // Gate by default: an unset `auth` defers to the handler + // (devframe's interactive OTP unless `cli.auth` opts out) rather + // than leaving the socket ungated. `false` opts out explicitly. auth: options.auth, mcp: options.mcp, }) + server.middlewares.use(created.nodeMiddleware) + await created.ready + instance = created } catch (e) { diagnostics.DF0033({ id: d.id, reason: String(e), cause: e as Error }, { method: 'warn' }) return } - // The side-car listens on its own port, so the browser must target that - // port explicitly (it can't reach the WS on Vite's origin). The route is - // `/__ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS - // upgrade handler is bound there. The MCP route (when enabled) lives on - // the same side-car origin, advertised with the same explicit port. - const mcpMeta = resolveMcpConnectionMeta(d, options.mcp, port) - const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` - server.middlewares.use(metaPath, (_req: unknown, res: any) => { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ - backend: 'websocket', - websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, - ...(mcpMeta ? { mcp: mcpMeta } : {}), - })) - }) - server.httpServer?.once('close', () => { - void started?.close().catch(() => {}) + void instance?.close().catch(() => {}) }) }, async closeBundle() { - await started?.close().catch(() => {}) - started = undefined + await instance?.close().catch(() => {}) + instance = undefined }, } } diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 262fa823..451d7a98 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -49,6 +49,14 @@ export interface StartHttpAndWsOptions { * only used to report the resolved origin. */ server?: NodeHttpServer + /** + * Destroy upgrade requests on a shared `server` that don't match `path`, + * instead of leaving them for the caller's other upgrade handlers. Enable + * when the caller owns the server outright but composes it through the + * shared-`server` path (e.g. `createDevServer` over `createHandler`). + * Defaults to whether this call created the server itself. + */ + destroyUnmatched?: boolean /** * Authentication for the server: * @@ -187,8 +195,9 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise /** - * Whether the side-car runs its own auth gate. **Gates by default** (defers - * to `createDevServer` — devframe's interactive OTP unless the definition's - * `cli.auth` opts out), so the side-car socket isn't silently reachable by - * anything that can open it. Pass `false` to opt out for a single-user - * localhost host, or a handler for a custom scheme. + * Whether the side-car runs its own auth gate. **Gates by default** + * (devframe's interactive OTP unless the definition's `cli.auth` opts + * out), so the side-car socket isn't silently reachable by anything that + * can open it. Pass `false` to opt out for a single-user localhost host, + * or a handler for a custom scheme. */ - auth?: CreateDevServerOptions['auth'] + auth?: InitDevframeOptions['auth'] /** Origin the Next app is reachable at, for docks needing an absolute URL. */ resolveOrigin?: () => string /** Override where persisted devframe state lives (defaults under the cwd / home). */ getStorageDir?: (scope: DevframeStorageScope) => string /** - * Expose the side-car's route-based MCP server (Streamable-HTTP) and - * advertise it in the handler's `__connection.json`. Forwarded to - * `createDevServer`: overrides `def.cli?.mcp`, `undefined` falls through to - * it, `false` disables the route regardless. The endpoint lives on the - * side-car's own port, so the advertised meta carries `{ port, path }`. + * Expose the route-based MCP server (Streamable-HTTP) at `__mcp` — + * on the Next app's own origin, through the same catch-all route as the + * SPA — and advertise it in the handler's `__connection.json`. Overrides + * `def.cli?.mcp`, `undefined` falls through to it, `false` disables the + * route regardless. * * @experimental */ - mcp?: CreateDevServerOptions['mcp'] + mcp?: InitDevframeOptions['mcp'] + /** + * Memoization key for the underlying `initDevframe` instance. Next re-runs + * route modules across dev-time reloads; the key makes a re-run return the + * live instance instead of leaking side-car servers. Default: + * `@devframes/next::`. + */ + key?: string } export interface DevframeNextHandler { /** * WHATWG-`fetch` handler for the catch-all App Router route. Serves the - * plugin's built SPA at `base` and answers `/__connection.json` with - * the side-car WS endpoint. Awaits {@link DevframeNextHandler.ready} so the - * first request doesn't race the server boot. + * plugin's built SPA at `base` and answers `__connection.json` with + * the RPC endpoint. Awaits {@link DevframeNextHandler.ready} so the first + * request doesn't race the server boot. */ fetch: (request: Request) => Promise /** Resolves once the side-car RPC/WS server is listening. */ @@ -74,16 +78,13 @@ function defaultGetStorageDir(scope: DevframeStorageScope): string { } /** - * Host a **single** devframe from a Next.js App Router app — the convenience - * wrapper over {@link createDevframeNextHost} for the common case of mounting - * one plugin (the Next counterpart to `viteDevBridge`'s bridge mode). + * Host a **single** devframe from a Next.js App Router app — the Next + * counterpart to `viteDevBridge`, reduced to memoization + defaults over + * `initDevframe` (Next's route handlers can't accept WS upgrades, so the + * RPC socket lives on the instance's side-car port, advertised at + * `__connection.json`). * - * It statically serves `def.cli.distDir` at `base` through the Next route and - * starts a side-car RPC/WS dev server (via `createDevServer` in bridge mode) on - * its own port, advertising that endpoint at `/__connection.json` so the - * SPA's `connectDevframe()` can dial back in. - * - * ```ts [app/__my-tool/[[...path]]/route.ts] + * ```ts [app/%5F_my-tool/[[...path]]/route.ts] * import myDevframe from '@/devframe' * import { createDevframeNextHandler } from '@devframes/next' * @@ -94,7 +95,7 @@ function defaultGetStorageDir(scope: DevframeStorageScope): string { * export const GET = handler.fetch * ``` * - * For a hub hosting many devframes at once, use {@link createDevframeNextHost} + * For a hub hosting many devframes at once, use `createDevframeNextHost` * directly with `@devframes/hub`. */ export function createDevframeNextHandler( @@ -109,48 +110,25 @@ export function createDevframeNextHandler( } const base = normalizeBase(options.base ?? def.basePath ?? `/__${def.id}/`) - const hostName = options.host ?? def.cli?.host - const nextHost = createDevframeNextHost({ - resolveOrigin: options.resolveOrigin ?? (() => ''), + const instance = initDevframe(def, { + base, + distDir, + host: options.host, + flags: options.flags, + // Gate by default: an unset `auth` defers to the instance (devframe's + // interactive OTP unless `cli.auth` opts out). `false` opts out. + auth: options.auth, + mcp: options.mcp, + ...(options.port != null ? { ws: { port: options.port } } : {}), + ...(options.resolveOrigin ? { origin: options.resolveOrigin } : {}), getStorageDir: options.getStorageDir ?? defaultGetStorageDir, + key: options.key ?? `@devframes/next:${def.id}:${base}`, }) - nextHost.host.mountStatic(base, distDir) - nextHost.host.mountConnectionMeta?.(base) - - let started: StartedServer | undefined - const ready = (async () => { - const port = options.port ?? await resolveDevServerPort(def, { host: hostName }) - // Bridge mode: no `distDir` passed, so the side-car serves only the WS - // endpoint + meta on its own port; the Next route serves the SPA. The - // side-car mounts the WS at `/` on the standalone base. - started = await createDevServer(def, { - host: hostName, - port, - flags: options.flags, - openBrowser: false, - // Gate by default: an unset `auth` defers to `createDevServer` rather - // than leaving the side-car socket ungated. `false` opts out explicitly. - auth: options.auth, - mcp: options.mcp, - }) - const mcpMeta = resolveMcpConnectionMeta(def, options.mcp, port) - nextHost.setConnectionMeta({ - backend: 'websocket', - websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, - ...(mcpMeta ? { mcp: mcpMeta } : {}), - }) - })() return { - async fetch(request) { - await ready - return nextHost.fetch(request) - }, - ready, - async close() { - await ready.catch(() => {}) - await started?.close() - }, + fetch: request => instance.handler(request), + ready: instance.ready, + close: instance.close, } } diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index 5d3f5b64..f9809f30 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -50,7 +50,7 @@ describe('createDevframeNextHandler', () => { const body = await meta.json() as { backend: string, websocket: { port: number, path: string } } expect(body.backend).toBe('websocket') expect(typeof body.websocket.port).toBe('number') - expect(body.websocket.path).toBe('/__ws') + expect(body.websocket.path).toBe('__ws') // Unmounted base → bare 404. const miss = await handler.fetch(new Request(`${origin}/__other/x`)) @@ -76,43 +76,43 @@ describe('createDevframeNextHandler', () => { websocket: { port: number, path: string } mcp?: { port: number, path: string } } - expect(body.mcp).toEqual({ port: body.websocket.port, path: '/__mcp' }) - - // The advertised endpoint answers MCP initialize on the side-car origin - // when a loopback Origin (required by the route's gate) is presented. - const sidecarOrigin = `http://127.0.0.1:${body.mcp!.port}` - const init = await fetch(`${sidecarOrigin}${body.mcp!.path}`, { + // The MCP route lives on the Next app's own origin now — a same-origin + // relative path next to __connection.json, served through the same + // catch-all route as the SPA. + expect(body.mcp).toEqual({ path: '__mcp' }) + + // The advertised endpoint answers MCP initialize through the route + // handler when a loopback Origin (required by the route's gate) is + // presented. + const origin = 'http://localhost:3000' + const initBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }) + const init = await handler.fetch(new Request(`${origin}/__test-next/__mcp`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', - 'origin': sidecarOrigin, + origin, }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, - }), - }) + body: initBody, + })) expect(init.status).toBe(200) expect(init.headers.get('mcp-session-id')).toBeTruthy() await init.body?.cancel() // Without an Origin header the same request is rejected. - const unauthed = await fetch(`${sidecarOrigin}${body.mcp!.path}`, { + const unauthed = await handler.fetch(new Request(`${origin}/__test-next/__mcp`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, - }), - }) + body: initBody, + })) await unauthed.body?.cancel() expect(unauthed.status).toBe(403) }) diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts index a691f0cb..389cc0ee 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -7,10 +7,11 @@ export interface CreateDevframeNextHandlerOptions { host?: string; port?: number; flags?: Record; - auth?: CreateDevServerOptions['auth']; + auth?: InitDevframeOptions['auth']; resolveOrigin?: () => string; getStorageDir?: (_: DevframeStorageScope) => string; - mcp?: CreateDevServerOptions['mcp']; + mcp?: InitDevframeOptions['mcp']; + key?: string; } export interface CreateDevframeNextHostOptions { resolveOrigin: () => string; diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 37d73498..88043ce8 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -21,14 +21,14 @@ export interface CreateDevServerOptions { app: H3; }) => void | Promise; } -export interface ResolveDevServerPortOptions { - host?: string; - defaultPort?: number; -} // #endregion // #region Functions export declare function createDevServer(_: DevframeDefinition, _?: CreateDevServerOptions): Promise; -export declare function resolveDevServerPort(_: DevframeDefinition, _?: ResolveDevServerPortOptions): Promise; -export declare function resolveMcpConnectionMeta(_: DevframeDefinition, _: boolean | McpRouteOptions | undefined, _?: number): ConnectionMeta['mcp']; +// #endregion + +// #region Other +export { resolveDevServerPort } +export { ResolveDevServerPortOptions } +export { resolveMcpConnectionMeta } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts index 9649aac8..541603bf 100644 --- a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts @@ -2,17 +2,16 @@ * Generated by tsnapi — public API snapshot of `devframe/helpers/vite` */ // #region Interfaces +export interface DevframeViteDevServerLike { + middlewares: { + use: ((_: string, _: (_: IncomingMessage, _: ServerResponse, _?: (_?: unknown) => void) => void) => void) & ((_: (_: IncomingMessage, _: ServerResponse, _?: (_?: unknown) => void) => void) => void); + }; + httpServer?: Server | null; +} export interface DevframeVitePlugin { name: string; apply: 'serve'; - configureServer: (_: { - middlewares: { - use: (_: string, _: any) => void; - }; - httpServer?: { - once: (_: 'close', _: () => void) => void; - } | null; - }) => void | Promise; + configureServer: (_: DevframeViteDevServerLike) => void | Promise; closeBundle?: () => void | Promise; } export interface ViteDevBridgeOptions { diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts index b9b9918e..6d8211ac 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts @@ -11,6 +11,10 @@ export interface DevframeInstance { connectionMeta: () => ConnectionMeta; close: () => Promise; } +export interface DevframeInstanceInternals { + readonly started?: StartedServer; + readonly authHandler?: DevframeAuthHandler; +} export interface DevframeInstanceWebSocket { open: (_: unknown) => void; message: (_: unknown, _: unknown) => void; @@ -19,19 +23,25 @@ export interface DevframeInstanceWebSocket { } export interface InitDevframeOptions { base?: string; - distDir?: string; + distDir?: string | false; server?: Server; ws?: DevframeWsOptions; host?: string; auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; key?: string; - origin?: string; + origin?: string | (() => string); flags?: Record; allowedOrigins?: readonly string[] | WsOriginRegistry | false; + app?: H3; + getStorageDir?: (_: DevframeStorageScope) => string; + destroyUnmatchedUpgrades?: boolean; + onPeerConnect?: (_: Peer, _: DevframeNodeRpcSession) => void; + onPeerDisconnect?: (_: Peer, _: DevframeNodeRpcSessionMeta) => void; } // #endregion // #region Functions +export declare function getInstanceInternals(_: object): DevframeInstanceInternals; export declare function initDevframe(_: DevframeDefinition, _?: InitDevframeOptions): DevframeInstance; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.js b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.js index 8dd6bb13..5cc26bdf 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.js @@ -1,6 +1,7 @@ /** * Generated by tsnapi — public API snapshot of `devframe/initiate` */ -// #region Functions -export function initDevframe(_, _) {} +// #region Other +export { getInstanceInternals } +export { initDevframe } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts index f9dded1d..4c370584 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/hub-internals.snapshot.d.ts @@ -1,15 +1,12 @@ /** * Generated by tsnapi — public API snapshot of `devframe/node/hub-internals` */ -// #region Functions -export declare function normalizeBasePath(_: string): string; -export declare function resolveBasePath(_: DevframeDefinition, _: DevframeDeploymentKind): string; -// #endregion - // #region Other export { DevframeInternalContext } export { getInternalContext } export { InternalAnonymousAuthStorage } export { internalContextMap } +export { normalizeBasePath } export { RemoteTokenRecord } +export { resolveBasePath } // #endregion \ No newline at end of file