diff --git a/docs/content/1.guide/1.tutorial-server-data-inspector.md b/docs/content/1.guide/1.tutorial-server-data-inspector.md index e3178525..27e7b903 100644 --- a/docs/content/1.guide/1.tutorial-server-data-inspector.md +++ b/docs/content/1.guide/1.tutorial-server-data-inspector.md @@ -176,12 +176,14 @@ export function App() { To try what we've built, let Vite serve the UI and hand RPC traffic to devframe: ```ts [app/vite.config.ts] +import { fileURLToPath } from 'node:url' import { devframeViteBridge } from '@devframes/vite/single' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import dataInspectorFrame from '../src/node/data-inspector.ts' export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), // serve app/, wherever you run vite from base: './', // relative asset URLs, so the built UI works under any mount path build: { outDir: '../dist/client', emptyOutDir: true }, plugins: [ diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 23fe5103..446fa4e1 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -211,6 +211,48 @@ describe('adapters/handler', () => { } }) + it('nodeMiddleware at base `/` hands unmatched paths back to the host', async () => { + const host = '127.0.0.1' + const port = await getPort({ port: 18135, host }) + + let devtoolsRef!: ReturnType + const server = createServer((req, res) => { + devtoolsRef.nodeMiddleware(req, res, () => { + res.statusCode = 200 + res.setHeader('content-type', 'text/html') + res.end(`host app ${req.url}`) + }) + }) + // No distDir: the host app owns the pages, the devframe only its routes. + devtoolsRef = initDevframe(defineTestDef('handler-root'), { base: '/', auth: false, distDir: false, server }) + await new Promise(resolve => server.listen(port, host, resolve)) + + try { + await devtoolsRef.ready + + // Every path sits inside base `/`, but the devframe has no route for + // these, so next() ran and the host app answered. + for (const path of ['/', '/main.tsx', '/nested/page?x=1']) { + const res = await fetch(`http://${host}:${port}${path}`) + expect(res.status).toBe(200) + expect(await res.text()).toBe(`host app ${path}`) + } + + // The devframe's own routes still answer. + const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' }, sse: { path: '__sse' } }) + + // Without a `next`, a miss is still the devframe's own 404. + const bare = await devtoolsRef.handler(new Request(`http://${host}:${port}/main.tsx`)) + expect(bare.status).toBe(404) + } + finally { + await devtoolsRef.close() + server.close() + server.closeAllConnections() + } + }) + it('shared-server tier: a TLS host server advertises wss://', async () => { const host = '127.0.0.1' const server = createSecureServer({ allowHTTP1: true }) diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 8b554e11..bfcf90a3 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -175,7 +175,9 @@ export interface DevframeInstance { * Connect/Express-style middleware over the same surface, for * `viteServer.middlewares.use(handler.nodeMiddleware)` or any other * node middleware stack. Mount it un-prefixed: paths outside the base - * call `next()` so the rest of the stack keeps working. + * call `next()` so the rest of the stack keeps working. At base `/`, a + * path no devframe route answers calls `next()` too, so the host app + * keeps its own pages. */ nodeMiddleware: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void /** diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 53b7c9db..450f9ad9 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -17,7 +17,7 @@ import process from 'node:process' import { Server as TlsServer } from 'node:tls' import { validateOriginCandidate } from 'devframe/utils/origin' import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url' -import { defineHandler, H3 as H3App, toNodeHandler } from 'h3' +import { defineHandler, fromNodeHandler, H3 as H3App, toNodeHandler } from 'h3' import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' import { createInteractiveAuth } from '../recipes/interactive-auth' import { diagnostics } from './diagnostics' @@ -625,6 +625,35 @@ export function createInstanceShell( maybeRegister() } + /** + * The host stack's `next` for each in-flight `nodeMiddleware` request at + * base `/`, read by the fall-through middleware mounted at the end of + * `init()`. + */ + const hostFallthrough = new WeakMap void>() + + /** + * A devframe at `/` shares the origin root with its host, so it can't claim + * every path the way it owns a `/__/` base. A `nodeMiddleware` request + * that no route answers goes to the host's `next` instead of h3's 404, so + * the host app (e.g. Vite with the bridge) keeps serving its own pages. + */ + function mountHostFallthrough(): void { + if (base !== '/') + return + app.use((event, next) => { + if (event.context.matchedRoute) + return next() + const nodeReq = event.runtime?.node?.req + const hostNext = nodeReq && hostFallthrough.get(nodeReq) + if (!hostNext) + return next() + hostFallthrough.delete(nodeReq) + // Resolves once the host stack has sent its own response. + return fromNodeHandler((_req, _res, _next) => hostNext())(event) + }) + } + let started: StartedServer | undefined let transport: WsRpcTransport | undefined let dispose: (() => Promise) | undefined @@ -835,6 +864,9 @@ export function createInstanceShell( await options.mount?.(ctx, meta, api) + // Last in the chain, after every route the instance mounts. + mountHostFallthrough() + // A pinned origin means the registry record needn't wait for a first // request. maybeRegister() @@ -911,6 +943,8 @@ export function createInstanceShell( const { toNodeHandler } = await import('h3/node') nodeHandler = toNodeHandler(app) } + if (next && base === '/') + hostFallthrough.set(req, () => next()) return nodeHandler(req, res) }) .catch((err: unknown) => { diff --git a/packages/vite/test/single.test.ts b/packages/vite/test/single.test.ts index 816bebd6..b114e92f 100644 --- a/packages/vite/test/single.test.ts +++ b/packages/vite/test/single.test.ts @@ -196,6 +196,31 @@ describe('devframeViteBridge (bridge mode mcp)', () => { rpc.$close?.() } }) + + it('at base `/`, leaves the host app\'s own pages to Vite', async () => { + const host = '127.0.0.1' + const vitePort = await getPort({ port: 19717, host }) + bridge = devframeViteBridge(defineTestDef(), { host, base: '/', auth: false }) + + vite = fakeViteServer() + await vite.listen(vitePort, host) + await bridge.configureServer(vite) + // Vite's own middlewares (transform, static, index.html) run after a + // plugin's `configureServer` middlewares. + vite.middlewares.use((req, res) => { + res.setHeader('content-type', 'text/html') + res.end(`vite ${req.url}`) + }) + + for (const path of ['/', '/main.tsx']) { + const res = await fetch(`http://${host}:${vitePort}${path}`) + expect(res.status).toBe(200) + expect(await res.text()).toBe(`vite ${path}`) + } + + const meta = await (await fetch(`http://${host}:${vitePort}/__connection.json`)).json() + expect(meta.websocket).toEqual({ path: '__ws' }) + }) }) describe('devframeViteBridge (auth default)', () => {