Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/1.guide/1.tutorial-server-data-inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
42 changes: 42 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof initDevframe>
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<void>(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 })
Expand Down
4 changes: 3 additions & 1 deletion packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
36 changes: 35 additions & 1 deletion packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -625,6 +625,35 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
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<object, () => void>()

/**
* A devframe at `/` shares the origin root with its host, so it can't claim
* every path the way it owns a `/__<id>/` 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<void>) | undefined
Expand Down Expand Up @@ -835,6 +864,9 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(

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()
Expand Down Expand Up @@ -911,6 +943,8 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
const { toNodeHandler } = await import('h3/node')
nodeHandler = toNodeHandler(app)
}
if (next && base === '/')
hostFallthrough.set(req, () => next())
return nodeHandler(req, res)
})
.catch((err: unknown) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/vite/test/single.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading