Skip to content

Commit f994c4b

Browse files
authored
feat!: migrate to h3 v2 (#4)
Bump the h3 catalog pin to 2.0.1-rc.22 and rewrite devframe's HTTP plumbing onto h3 v2's web-standard primitives. The h3 v1 `App` type is no longer exported in v2, so devframe's public surface — the optional `app` on `CreateDevServerOptions` / `StartHttpAndWsOptions`, the `app` field on `StartedServer`, and the `onReady({ app })` callback — switches from `App` to `H3`. Devframe bumps to `0.2.0` to reflect the breaking type rename. Internally: `createApp()` → `new H3()`, `toNodeListener` → `toNodeHandler`, `defineEventHandler` → `defineHandler`, `setResponseStatus`/`setResponseHeader` → `event.res.status` / `event.res.headers.set(...)`, and the static file stream returns via `Readable.toWeb(...)` instead of `sendStream`. The connection-meta handler in `adapters/dev.ts` collapses to a plain `() => ({...})` now that h3 auto-serializes object returns. The trickiest v2 behavior change is route matching: `app.use(base, h)` in v2 only matches the exact `base` path (not subpaths) and does not strip the prefix from `event.url.pathname`. Static-dir mounts need both. To avoid spreading that quirk across every call site, add a new `mountStaticHandler(app, base, dir, options?)` export in `devframe/utils/serve-static` that bundles the `${base}/**` route and `withBase(base, ...)` prefix stripping. All four production/test mount sites switch to it; the existing `serveStaticHandler` (h3 event handler) and `serveStaticNodeMiddleware` (Connect middleware) are kept unchanged. Snapshots in `tests/__snapshots__/tsnapi/` are regenerated to reflect the renamed types and the new `mountStaticHandler` export.
1 parent b933bec commit f994c4b

15 files changed

Lines changed: 124 additions & 134 deletions

File tree

examples/devframe-files-inspector/tests/_utils.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
createHostContext,
1313
startHttpAndWs,
1414
} from 'devframe/node'
15-
import { serveStaticHandler } from 'devframe/utils/serve-static'
15+
import { mountStaticHandler } from 'devframe/utils/serve-static'
1616
import { getPort } from 'get-port-please'
17-
import { createApp, eventHandler } from 'h3'
17+
import { H3 } from 'h3'
1818
import { resolve } from 'pathe'
1919
import devframe from '../src/devframe'
2020

@@ -66,30 +66,22 @@ export async function startInspectorServer(
6666
const host = '127.0.0.1'
6767
const port = await getPort({ host, random: true })
6868

69-
const app = createApp()
69+
const app = new H3()
7070
const origin = `http://${host}:${port}`
7171
const h3Host = createH3DevToolsHost({
7272
origin,
7373
appName: devframe.id,
7474
mount: (base, dir) => {
75-
app.use(base, serveStaticHandler(dir))
75+
mountStaticHandler(app, base, dir)
7676
},
7777
})
7878

7979
const ctx = await createHostContext({ cwd, mode: 'dev', host: h3Host })
8080
await devframe.setup(ctx)
8181

8282
const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}`
83-
app.use(
84-
metaPath,
85-
eventHandler((event) => {
86-
event.node.res.setHeader('Content-Type', 'application/json')
87-
return event.node.res.end(
88-
JSON.stringify({ backend: 'websocket', websocket: port }),
89-
)
90-
}),
91-
)
92-
app.use(basePath, serveStaticHandler(resolve(distDir)))
83+
app.use(metaPath, () => ({ backend: 'websocket', websocket: port }))
84+
mountStaticHandler(app, basePath, resolve(distDir))
9385

9486
const server = await startHttpAndWs({
9587
context: ctx,

examples/devframe-files-inspector/tests/static-serve.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { createServer } from 'node:http'
33
import os from 'node:os'
44
import path from 'node:path'
55
import { createBuild } from 'devframe/adapters/build'
6-
import { serveStaticHandler } from 'devframe/utils/serve-static'
6+
import { mountStaticHandler } from 'devframe/utils/serve-static'
77
import { getPort } from 'get-port-please'
8-
import { createApp, toNodeListener } from 'h3'
8+
import { H3, toNodeHandler } from 'h3'
99
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
1010
import devframe from '../src/devframe'
1111
import { assertClientBuilt, makeFixtureCwd } from './_utils'
@@ -20,9 +20,9 @@ interface StaticServer {
2020
async function startStaticServer(outDir: string, mountBase: string): Promise<StaticServer> {
2121
const host = '127.0.0.1'
2222
const port = await getPort({ host, random: true })
23-
const app = createApp()
24-
app.use(mountBase, serveStaticHandler(outDir))
25-
const httpServer = createServer(toNodeListener(app))
23+
const app = new H3()
24+
mountStaticHandler(app, mountBase, outDir)
25+
const httpServer = createServer(toNodeHandler(app))
2626
await new Promise<void>(r => httpServer.listen(port, host, () => r()))
2727
return {
2828
origin: `http://${host}:${port}`,

examples/devframe-streaming-chat/tests/_utils.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import {
1111
createHostContext,
1212
startHttpAndWs,
1313
} from 'devframe/node'
14-
import { serveStaticHandler } from 'devframe/utils/serve-static'
14+
import { mountStaticHandler } from 'devframe/utils/serve-static'
1515
import { getPort } from 'get-port-please'
16-
import { createApp, eventHandler } from 'h3'
16+
import { H3 } from 'h3'
1717
import { resolve } from 'pathe'
1818
import devframe from '../src/devframe'
1919

@@ -39,30 +39,21 @@ export async function startStreamingChatServer(): Promise<StartedServer & {
3939
const host = '127.0.0.1'
4040
const port = await getPort({ host, random: true })
4141

42-
const app = createApp()
42+
const app = new H3()
4343
const origin = `http://${host}:${port}`
4444
const h3Host = createH3DevToolsHost({
4545
origin,
4646
appName: devframe.id,
47-
mount: (base, dir) =>
48-
app.use(base, serveStaticHandler(dir)),
47+
mount: (base, dir) => mountStaticHandler(app, base, dir),
4948
})
5049

5150
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
5251
await devframe.setup(ctx)
5352

5453
const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}`
55-
app.use(
56-
metaPath,
57-
eventHandler((event) => {
58-
event.node.res.setHeader('Content-Type', 'application/json')
59-
return event.node.res.end(
60-
JSON.stringify({ backend: 'websocket', websocket: port }),
61-
)
62-
}),
63-
)
54+
app.use(metaPath, () => ({ backend: 'websocket', websocket: port }))
6455
if (existsSync(path.join(resolve(distDir), 'index.html'))) {
65-
app.use(basePath, serveStaticHandler(resolve(distDir)))
56+
mountStaticHandler(app, basePath, resolve(distDir))
6657
}
6758

6859
const server = await startHttpAndWs({

packages/devframe/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "devframe",
33
"type": "module",
4-
"version": "0.1.22",
4+
"version": "0.2.0",
55
"description": "Framework for building generic DevTools",
66
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
77
"license": "MIT",

packages/devframe/src/adapters/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CAC } from 'cac'
2-
import type { App } from 'h3'
2+
import type { H3 } from 'h3'
33
import type { DevframeDefinition } from '../types/devframe'
44
import process from 'node:process'
55
import cac from 'cac'
@@ -24,7 +24,7 @@ export interface CreateCliOptions {
2424
* Called once the dev server is listening. Use this to print a
2525
* startup banner or trigger side-effects that depend on the live URL.
2626
*/
27-
onReady?: (info: { origin: string, port: number, app: App }) => void | Promise<void>
27+
onReady?: (info: { origin: string, port: number, app: H3 }) => void | Promise<void>
2828
}
2929

3030
export interface CliHandle {

packages/devframe/src/adapters/dev.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import type { App } from 'h3'
21
import type { StartedServer } from '../node/server'
32
import type { DevframeDefinition, DevframeSetupInfo } from '../types/devframe'
43
import process from 'node:process'
54
import { getPort } from 'get-port-please'
6-
import { createApp, eventHandler } from 'h3'
5+
import { H3 } from 'h3'
76
import { resolve } from 'pathe'
87
import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants'
98
import { createHostContext } from '../node/context'
109
import { createH3DevToolsHost } from '../node/host-h3'
1110
import { startHttpAndWs } from '../node/server'
1211
import { open } from '../utils/open'
13-
import { serveStaticHandler } from '../utils/serve-static'
12+
import { mountStaticHandler } from '../utils/serve-static'
1413
import { normalizeBasePath, resolveBasePath } from './_shared'
1514

1615
const DEFAULT_PORT = 9999
@@ -49,7 +48,7 @@ export interface CreateDevServerOptions {
4948
* middleware (auth, logging, extra static assets) before devframe's
5049
* own handlers.
5150
*/
52-
app?: App
51+
app?: H3
5352
/**
5453
* Auto-open the browser. When `undefined` the resolution falls
5554
* through to `flags.open` (incl. string path) and finally
@@ -61,7 +60,7 @@ export interface CreateDevServerOptions {
6160
* Called once the WS server is bound. Devframe stays headless
6261
* otherwise — wire this if you want a startup banner.
6362
*/
64-
onReady?: (info: { origin: string, port: number, app: App }) => void | Promise<void>
63+
onReady?: (info: { origin: string, port: number, app: H3 }) => void | Promise<void>
6564
}
6665

6766
export interface ResolveDevServerPortOptions {
@@ -123,14 +122,14 @@ export async function createDevServer(
123122
const port = options.port ?? await resolveDevServerPort(def, { host })
124123
const flags = options.flags ?? {}
125124
const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone')
126-
const app = options.app ?? createApp()
125+
const app = options.app ?? new H3()
127126
const origin = `http://${host}:${port}`
128127

129128
const h3Host = createH3DevToolsHost({
130129
origin,
131130
appName: def.id,
132131
mount: (base, dir) => {
133-
app.use(base, serveStaticHandler(dir))
132+
mountStaticHandler(app, base, dir)
134133
},
135134
})
136135

@@ -148,13 +147,10 @@ export async function createDevServer(
148147
// sits at the SPA root (next to index.html) so the deployed SPA can
149148
// discover it via a relative `./__connection.json` fetch.
150149
const connectionMetaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}`
151-
app.use(connectionMetaPath, eventHandler((event) => {
152-
event.node.res.setHeader('Content-Type', 'application/json')
153-
return event.node.res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
154-
}))
150+
app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: port }))
155151

156152
if (distDir)
157-
app.use(basePath, serveStaticHandler(resolve(distDir)))
153+
mountStaticHandler(app, basePath, resolve(distDir))
158154

159155
return startHttpAndWs({
160156
context: ctx,

packages/devframe/src/node/server.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { BirpcGroup } from 'birpc'
22
import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types'
3-
import type { App } from 'h3'
43
import type { WebSocketServer } from 'ws'
54
import type { RpcFunctionsHost } from './host-functions'
65
import { AsyncLocalStorage } from 'node:async_hooks'
76
import { createServer } from 'node:http'
87
import { createRpcServer } from 'devframe/rpc/server'
98
import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server'
10-
import { createApp, toNodeListener } from 'h3'
9+
import { H3, toNodeHandler } from 'h3'
1110
import { WebSocketServer as WSServer } from 'ws'
1211

1312
export interface StartHttpAndWsOptions {
@@ -19,7 +18,7 @@ export interface StartHttpAndWsOptions {
1918
* when provided, callers can add their own routes (static handlers,
2019
* auth middleware, etc.) first.
2120
*/
22-
app?: App
21+
app?: H3
2322
/**
2423
* When `false`, the RPC server is started without a trust handshake.
2524
* Intended for single-user localhost tools where an auth round-trip
@@ -36,14 +35,14 @@ export interface StartHttpAndWsOptions {
3635
* handlers whose origin depends on the resolved port, or print their
3736
* own startup banner. Devframe does not print one itself.
3837
*/
39-
onReady?: (info: { origin: string, port: number, app: App }) => void | Promise<void>
38+
onReady?: (info: { origin: string, port: number, app: H3 }) => void | Promise<void>
4039
}
4140

4241
export interface StartedServer {
4342
/** Listening origin, e.g. `http://localhost:9999`. */
4443
origin: string
4544
port: number
46-
app: App
45+
app: H3
4746
wss: WebSocketServer
4847
rpcGroup: BirpcGroup<DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, false>
4948
close: () => Promise<void>
@@ -57,8 +56,8 @@ export interface StartedServer {
5756
export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<StartedServer> {
5857
const { context, port } = options
5958
const bindHost = options.host ?? 'localhost'
60-
const app = options.app ?? createApp()
61-
const httpServer = createServer(toNodeListener(app))
59+
const app = options.app ?? new H3()
60+
const httpServer = createServer(toNodeHandler(app))
6261
const wss = new WSServer({ server: httpServer })
6362
const rpcHost = context.rpc as unknown as RpcFunctionsHost
6463

packages/devframe/src/utils/serve-static.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
44
import { createServer } from 'node:http'
55
import { tmpdir } from 'node:os'
66
import { join } from 'node:path'
7-
import { createApp, toNodeListener } from 'h3'
7+
import { H3, toNodeHandler } from 'h3'
88
import { afterEach, describe, expect, it } from 'vitest'
99
import { serveStaticHandler, serveStaticNodeMiddleware } from './serve-static'
1010

@@ -19,9 +19,9 @@ function makeTmp(prefix = 'devframe-serve-'): string {
1919
}
2020

2121
async function startH3(dir: string, options?: ServeStaticOptions): Promise<Fixture> {
22-
const app = createApp()
22+
const app = new H3()
2323
app.use(serveStaticHandler(dir, options))
24-
const server = createServer(toNodeListener(app))
24+
const server = createServer(toNodeHandler(app))
2525
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
2626
const port = (server.address() as AddressInfo).port
2727
return {

packages/devframe/src/utils/serve-static.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { EventHandler, EventHandlerRequest } from 'h3'
1+
import type { EventHandler, H3 } from 'h3'
22
import type { IncomingMessage, ServerResponse } from 'node:http'
33
import { createReadStream } from 'node:fs'
44
import { stat } from 'node:fs/promises'
5-
import { defineEventHandler, sendStream, setResponseHeader, setResponseStatus } from 'h3'
5+
import { Readable } from 'node:stream'
6+
import { defineHandler, withBase } from 'h3'
67
import { lookup } from 'mrmime'
78
import { extname, join, normalize, resolve, sep } from 'pathe'
89

@@ -105,11 +106,18 @@ function contentTypeFor(abs: string): string {
105106
return type
106107
}
107108

108-
function setStaticHeaders(res: ServerResponse, file: ResolvedFile): void {
109-
res.setHeader('Content-Type', contentTypeFor(file.abs))
110-
res.setHeader('Content-Length', file.size)
111-
res.setHeader('Last-Modified', file.mtime.toUTCString())
112-
res.setHeader('Cache-Control', 'no-store')
109+
function staticHeadersFor(file: ResolvedFile): Record<string, string> {
110+
return {
111+
'Content-Type': contentTypeFor(file.abs),
112+
'Content-Length': String(file.size),
113+
'Last-Modified': file.mtime.toUTCString(),
114+
'Cache-Control': 'no-store',
115+
}
116+
}
117+
118+
function applyStaticHeadersToNode(res: ServerResponse, file: ResolvedFile): void {
119+
for (const [k, v] of Object.entries(staticHeadersFor(file)))
120+
res.setHeader(k, v)
113121
}
114122

115123
interface NormalizedOptions {
@@ -136,31 +144,53 @@ function normalizeOptions(options: ServeStaticOptions | undefined): NormalizedOp
136144
export function serveStaticHandler(
137145
dir: string,
138146
options?: ServeStaticOptions,
139-
): EventHandler<EventHandlerRequest> {
147+
): EventHandler {
140148
const absDir = resolve(dir)
141149
const opts = normalizeOptions(options)
142-
return defineEventHandler(async (event) => {
143-
const method = event.node.req.method
150+
return defineHandler(async (event) => {
151+
const method = event.req.method
144152
if (method !== 'GET' && method !== 'HEAD') {
145-
setResponseStatus(event, 405)
146-
setResponseHeader(event, 'Allow', 'GET, HEAD')
153+
event.res.status = 405
154+
event.res.headers.set('Allow', 'GET, HEAD')
147155
return ''
148156
}
149-
const url = event.node.req.url ?? '/'
150-
const file = await resolveTarget(absDir, url, opts.indexNames, opts.single)
157+
const file = await resolveTarget(absDir, event.url.pathname, opts.indexNames, opts.single)
151158
if (!file) {
152-
setResponseStatus(event, 404)
159+
event.res.status = 404
153160
return ''
154161
}
155-
setStaticHeaders(event.node.res, file)
156-
if (method === 'HEAD') {
157-
event.node.res.end()
162+
for (const [k, v] of Object.entries(staticHeadersFor(file)))
163+
event.res.headers.set(k, v)
164+
if (method === 'HEAD')
158165
return ''
159-
}
160-
return sendStream(event, createReadStream(file.abs))
166+
return Readable.toWeb(createReadStream(file.abs)) as ReadableStream
161167
})
162168
}
163169

170+
/**
171+
* Mount {@link serveStaticHandler} on an h3 app at `base`, handling the
172+
* route pattern and prefix-stripping required by h3 v2.
173+
*
174+
* h3 v2's `app.use(base, handler)` only matches the exact `base` path and
175+
* does not strip the prefix from `event.url.pathname`. Static serving
176+
* needs both subpath matching (`/base/**`) and the URL stripped so the
177+
* file resolver sees paths relative to `dir` — this helper bundles both.
178+
*/
179+
export function mountStaticHandler(
180+
app: H3,
181+
base: string,
182+
dir: string,
183+
options?: ServeStaticOptions,
184+
): void {
185+
const trimmed = base.replace(/\/$/, '')
186+
const handler = serveStaticHandler(dir, options)
187+
if (trimmed === '') {
188+
app.use('/**', handler)
189+
return
190+
}
191+
app.use(`${trimmed}/**`, withBase(trimmed, handler))
192+
}
193+
164194
/**
165195
* Connect/Express-style Node middleware variant of {@link serveStaticHandler}.
166196
*
@@ -198,7 +228,7 @@ export function serveStaticNodeMiddleware(
198228
res.end()
199229
return
200230
}
201-
setStaticHeaders(res, file)
231+
applyStaticHeadersToNode(res, file)
202232
if (method === 'HEAD') {
203233
res.end()
204234
return

0 commit comments

Comments
 (0)