Skip to content

Commit 977a242

Browse files
bilal-azamusualoma
andauthored
feat: add Early Hints (HTTP 103) middleware (#378)
* (feat): add writeEarlyHints helper for HTTP 103 Early Hints support * refactor(early-hints): rework to middleware-only API per review Replace the exported writeEarlyHints helper with an earlyHints middleware exposed only from the ./early-hints subpath. Options are flattened to accept link as a string, array, or context function. Warns once per middleware instance when writeEarlyHints is unavailable and no-ops when headers are already sent. * fix(early-hints): preserve middleware env types * docs: simplify early hints middleware usage * feat(early-hints): filter non-document requests * docs: note Sec-Fetch filtering behaviour for Early Hints --------- Co-authored-by: Taku Amano <taku@taaas.jp>
1 parent a813b6c commit 977a242

5 files changed

Lines changed: 573 additions & 2 deletions

File tree

README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ app.get(
8686
}))
8787
)
8888

89-
const wss = new WebSocketServer({ noServer: true }) // important to create with `noServer: true`
89+
const wss = new WebSocketServer({ noServer: true })
9090
serve({
9191
fetch: app.fetch,
9292
websocket: { server: wss },
@@ -332,6 +332,52 @@ type Http2Bindings = {
332332
}
333333
```
334334
335+
## Early Hints Middleware
336+
337+
You can send HTTP 103 Early Hints to instruct browsers to preload or preconnect resources before the final response is prepared. The middleware is supported under Node.js bindings (HTTP/1.1 and HTTP/2).
338+
339+
### Usage
340+
341+
Import `earlyHints` from `@hono/node-server/early-hints`:
342+
343+
#### Static links
344+
345+
```ts
346+
import { serve } from '@hono/node-server'
347+
import { earlyHints } from '@hono/node-server/early-hints'
348+
import { Hono } from 'hono'
349+
350+
const app = new Hono()
351+
352+
app.use(
353+
earlyHints({
354+
link: '</styles.css>; rel=preload; as=style',
355+
})
356+
)
357+
358+
app.get('/', (c) => {
359+
return c.html('<!DOCTYPE html><html><body><h1>Hello Hono!</h1></body></html>')
360+
})
361+
362+
serve(app)
363+
```
364+
365+
#### Dynamic links
366+
367+
```ts
368+
app.use(
369+
earlyHints({
370+
link: (c) =>
371+
c.req.query('theme') === 'dark'
372+
? '</dark.css>; rel=preload; as=style'
373+
: '</light.css>; rel=preload; as=style',
374+
})
375+
)
376+
```
377+
378+
> [!NOTE]
379+
> Early Hints are sent only for requests that look like document navigations. If `Sec-Fetch-Mode` or `Sec-Fetch-Dest` is present with a value other than `navigate` or `document`, for example a `fetch()` or XHR call from a browser, a subresource request, or an iframe navigation, the middleware skips the hints and continues to the handler. Requests without these headers, such as `curl` or `fetch()` from a JavaScript runtime, are treated as navigations and do receive Early Hints.
380+
335381
## Direct response from Node.js API
336382

337383
You can directly respond to the client from the Node.js API.

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@
4848
"types": "./dist/conninfo.d.cts",
4949
"default": "./dist/conninfo.cjs"
5050
}
51+
},
52+
"./early-hints": {
53+
"import": {
54+
"types": "./dist/early-hints.d.mts",
55+
"default": "./dist/early-hints.mjs"
56+
},
57+
"require": {
58+
"types": "./dist/early-hints.d.cts",
59+
"default": "./dist/early-hints.cjs"
60+
}
5161
}
5262
},
5363
"typesVersions": {
@@ -63,6 +73,9 @@
6373
],
6474
"conninfo": [
6575
"./dist/conninfo.d.mts"
76+
],
77+
"early-hints": [
78+
"./dist/early-hints.d.mts"
6679
]
6780
}
6881
},

src/early-hints.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Context, Env, MiddlewareHandler } from 'hono'
2+
import type { HttpBindings } from './types'
3+
4+
export type EarlyHintsOptions<E extends Env = Env> = {
5+
link: string | string[] | ((c: Context<E>) => string | string[] | undefined)
6+
}
7+
8+
/**
9+
* Early Hints middleware for Node.js
10+
* Automatically sends a 103 Early Hints informational response with the specified Link header(s).
11+
*
12+
* @param options EarlyHintsOptions
13+
* @returns MiddlewareHandler
14+
*/
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
export const earlyHints = <E extends Env = any>(
17+
options: EarlyHintsOptions<E>
18+
): MiddlewareHandler<E> => {
19+
let warned = false
20+
21+
return async (c, next) => {
22+
const mode = c.req.header('Sec-Fetch-Mode')
23+
const dest = c.req.header('Sec-Fetch-Dest')
24+
25+
if ((mode && mode !== 'navigate') || (dest && dest !== 'document')) {
26+
return next()
27+
}
28+
29+
const env = c.env || {}
30+
const bindings = (env.server ? env.server : env) as HttpBindings
31+
const outgoing = bindings?.outgoing
32+
33+
// Capability check: outgoing.writeEarlyHints exists and is a function.
34+
// This guard exists for non-Node runtimes and non-HTTP bindings.
35+
if (typeof outgoing?.writeEarlyHints !== 'function') {
36+
if (!warned) {
37+
console.warn(
38+
'Early Hints Middleware is not supported because writeEarlyHints is not defined.'
39+
)
40+
warned = true
41+
}
42+
return await next()
43+
}
44+
45+
if (!outgoing.headersSent) {
46+
const link = typeof options.link === 'function' ? options.link(c) : options.link
47+
48+
if (link !== undefined && (Array.isArray(link) ? link.length > 0 : Boolean(link))) {
49+
outgoing.writeEarlyHints({ link })
50+
}
51+
}
52+
53+
await next()
54+
}
55+
}

0 commit comments

Comments
 (0)