Skip to content

Commit 463f7d7

Browse files
committed
fix(devframe): let a devframe at base / fall through to the host app
A devframe mounted at `/` through `nodeMiddleware` answered every unmatched path with h3's 404, so `devframeViteBridge(def, { base: '/' })` hid the Vite app's own pages. Unmatched requests at base `/` now call the host's `next()`. Bases like `/__<id>/` keep owning their namespace. The tutorial's Vite config also sets `root` to `app/`, so `vite --config app/vite.config.ts` serves `app/index.html`. Fixes #456
1 parent 705a573 commit 463f7d7

5 files changed

Lines changed: 107 additions & 2 deletions

File tree

‎docs/content/1.guide/1.tutorial-server-data-inspector.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,14 @@ export function App() {
176176
To try what we've built, let Vite serve the UI and hand RPC traffic to devframe:
177177

178178
```ts [app/vite.config.ts]
179+
import { fileURLToPath } from 'node:url'
179180
import { devframeViteBridge } from '@devframes/vite/single'
180181
import react from '@vitejs/plugin-react'
181182
import { defineConfig } from 'vite'
182183
import dataInspectorFrame from '../src/node/data-inspector.ts'
183184

184185
export default defineConfig({
186+
root: fileURLToPath(new URL('.', import.meta.url)), // serve app/, wherever you run vite from
185187
base: './', // relative asset URLs, so the built UI works under any mount path
186188
build: { outDir: '../dist/client', emptyOutDir: true },
187189
plugins: [

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,48 @@ describe('adapters/handler', () => {
211211
}
212212
})
213213

214+
it('nodeMiddleware at base `/` hands unmatched paths back to the host', async () => {
215+
const host = '127.0.0.1'
216+
const port = await getPort({ port: 18135, host })
217+
218+
let devtoolsRef!: ReturnType<typeof initDevframe>
219+
const server = createServer((req, res) => {
220+
devtoolsRef.nodeMiddleware(req, res, () => {
221+
res.statusCode = 200
222+
res.setHeader('content-type', 'text/html')
223+
res.end(`host app ${req.url}`)
224+
})
225+
})
226+
// No distDir: the host app owns the pages, the devframe only its routes.
227+
devtoolsRef = initDevframe(defineTestDef('handler-root'), { base: '/', auth: false, distDir: false, server })
228+
await new Promise<void>(resolve => server.listen(port, host, resolve))
229+
230+
try {
231+
await devtoolsRef.ready
232+
233+
// Every path sits inside base `/`, but the devframe has no route for
234+
// these, so next() ran and the host app answered.
235+
for (const path of ['/', '/main.tsx', '/nested/page?x=1']) {
236+
const res = await fetch(`http://${host}:${port}${path}`)
237+
expect(res.status).toBe(200)
238+
expect(await res.text()).toBe(`host app ${path}`)
239+
}
240+
241+
// The devframe's own routes still answer.
242+
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
243+
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' }, sse: { path: '__sse' } })
244+
245+
// Without a `next`, a miss is still the devframe's own 404.
246+
const bare = await devtoolsRef.handler(new Request(`http://${host}:${port}/main.tsx`))
247+
expect(bare.status).toBe(404)
248+
}
249+
finally {
250+
await devtoolsRef.close()
251+
server.close()
252+
server.closeAllConnections()
253+
}
254+
})
255+
214256
it('shared-server tier: a TLS host server advertises wss://', async () => {
215257
const host = '127.0.0.1'
216258
const server = createSecureServer({ allowHTTP1: true })

‎packages/devframe/src/adapters/initiate.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ export interface DevframeInstance {
175175
* Connect/Express-style middleware over the same surface, for
176176
* `viteServer.middlewares.use(handler.nodeMiddleware)` or any other
177177
* node middleware stack. Mount it un-prefixed: paths outside the base
178-
* call `next()` so the rest of the stack keeps working.
178+
* call `next()` so the rest of the stack keeps working. At base `/`, a
179+
* path no devframe route answers calls `next()` too, so the host app
180+
* keeps its own pages.
179181
*/
180182
nodeMiddleware: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void
181183
/**

‎packages/devframe/src/node/instance-shell.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import process from 'node:process'
1717
import { Server as TlsServer } from 'node:tls'
1818
import { validateOriginCandidate } from 'devframe/utils/origin'
1919
import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url'
20-
import { defineHandler, H3 as H3App, toNodeHandler } from 'h3'
20+
import { defineHandler, fromNodeHandler, H3 as H3App, toNodeHandler } from 'h3'
2121
import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
2222
import { createInteractiveAuth } from '../recipes/interactive-auth'
2323
import { diagnostics } from './diagnostics'
@@ -625,6 +625,35 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
625625
maybeRegister()
626626
}
627627

628+
/**
629+
* The host stack's `next` for each in-flight `nodeMiddleware` request at
630+
* base `/`, read by the fall-through middleware mounted at the end of
631+
* `init()`.
632+
*/
633+
const hostFallthrough = new WeakMap<object, () => void>()
634+
635+
/**
636+
* A devframe at `/` shares the origin root with its host, so it can't claim
637+
* every path the way it owns a `/__<id>/` base. A `nodeMiddleware` request
638+
* that no route answers goes to the host's `next` instead of h3's 404, so
639+
* the host app (e.g. Vite with the bridge) keeps serving its own pages.
640+
*/
641+
function mountHostFallthrough(): void {
642+
if (base !== '/')
643+
return
644+
app.use((event, next) => {
645+
if (event.context.matchedRoute)
646+
return next()
647+
const nodeReq = event.runtime?.node?.req
648+
const hostNext = nodeReq && hostFallthrough.get(nodeReq)
649+
if (!hostNext)
650+
return next()
651+
hostFallthrough.delete(nodeReq)
652+
// Resolves once the host stack has sent its own response.
653+
return fromNodeHandler((_req, _res, _next) => hostNext())(event)
654+
})
655+
}
656+
628657
let started: StartedServer | undefined
629658
let transport: WsRpcTransport | undefined
630659
let dispose: (() => Promise<void>) | undefined
@@ -835,6 +864,9 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
835864

836865
await options.mount?.(ctx, meta, api)
837866

867+
// Last in the chain, after every route the instance mounts.
868+
mountHostFallthrough()
869+
838870
// A pinned origin means the registry record needn't wait for a first
839871
// request.
840872
maybeRegister()
@@ -911,6 +943,8 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
911943
const { toNodeHandler } = await import('h3/node')
912944
nodeHandler = toNodeHandler(app)
913945
}
946+
if (next && base === '/')
947+
hostFallthrough.set(req, () => next())
914948
return nodeHandler(req, res)
915949
})
916950
.catch((err: unknown) => {

‎packages/vite/test/single.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,31 @@ describe('devframeViteBridge (bridge mode mcp)', () => {
196196
rpc.$close?.()
197197
}
198198
})
199+
200+
it('at base `/`, leaves the host app\'s own pages to Vite', async () => {
201+
const host = '127.0.0.1'
202+
const vitePort = await getPort({ port: 19717, host })
203+
bridge = devframeViteBridge(defineTestDef(), { host, base: '/', auth: false })
204+
205+
vite = fakeViteServer()
206+
await vite.listen(vitePort, host)
207+
await bridge.configureServer(vite)
208+
// Vite's own middlewares (transform, static, index.html) run after a
209+
// plugin's `configureServer` middlewares.
210+
vite.middlewares.use((req, res) => {
211+
res.setHeader('content-type', 'text/html')
212+
res.end(`vite ${req.url}`)
213+
})
214+
215+
for (const path of ['/', '/main.tsx']) {
216+
const res = await fetch(`http://${host}:${vitePort}${path}`)
217+
expect(res.status).toBe(200)
218+
expect(await res.text()).toBe(`vite ${path}`)
219+
}
220+
221+
const meta = await (await fetch(`http://${host}:${vitePort}/__connection.json`)).json()
222+
expect(meta.websocket).toEqual({ path: '__ws' })
223+
})
199224
})
200225

201226
describe('devframeViteBridge (auth default)', () => {

0 commit comments

Comments
 (0)