diff --git a/README.md b/README.md
index 7116eabe..1ef0b4de 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ app.get(
}))
)
-const wss = new WebSocketServer({ noServer: true }) // important to create with `noServer: true`
+const wss = new WebSocketServer({ noServer: true })
serve({
fetch: app.fetch,
websocket: { server: wss },
@@ -332,6 +332,52 @@ type Http2Bindings = {
}
```
+## Early Hints Middleware
+
+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).
+
+### Usage
+
+Import `earlyHints` from `@hono/node-server/early-hints`:
+
+#### Static links
+
+```ts
+import { serve } from '@hono/node-server'
+import { earlyHints } from '@hono/node-server/early-hints'
+import { Hono } from 'hono'
+
+const app = new Hono()
+
+app.use(
+ earlyHints({
+ link: '; rel=preload; as=style',
+ })
+)
+
+app.get('/', (c) => {
+ return c.html('
Hello Hono!
')
+})
+
+serve(app)
+```
+
+#### Dynamic links
+
+```ts
+app.use(
+ earlyHints({
+ link: (c) =>
+ c.req.query('theme') === 'dark'
+ ? '; rel=preload; as=style'
+ : '; rel=preload; as=style',
+ })
+)
+```
+
+> [!NOTE]
+> 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.
+
## Direct response from Node.js API
You can directly respond to the client from the Node.js API.
diff --git a/package.json b/package.json
index c90eb758..3a7b1a0e 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,16 @@
"types": "./dist/conninfo.d.cts",
"default": "./dist/conninfo.cjs"
}
+ },
+ "./early-hints": {
+ "import": {
+ "types": "./dist/early-hints.d.mts",
+ "default": "./dist/early-hints.mjs"
+ },
+ "require": {
+ "types": "./dist/early-hints.d.cts",
+ "default": "./dist/early-hints.cjs"
+ }
}
},
"typesVersions": {
@@ -63,6 +73,9 @@
],
"conninfo": [
"./dist/conninfo.d.mts"
+ ],
+ "early-hints": [
+ "./dist/early-hints.d.mts"
]
}
},
diff --git a/src/early-hints.ts b/src/early-hints.ts
new file mode 100644
index 00000000..ecbf026e
--- /dev/null
+++ b/src/early-hints.ts
@@ -0,0 +1,55 @@
+import type { Context, Env, MiddlewareHandler } from 'hono'
+import type { HttpBindings } from './types'
+
+export type EarlyHintsOptions = {
+ link: string | string[] | ((c: Context) => string | string[] | undefined)
+}
+
+/**
+ * Early Hints middleware for Node.js
+ * Automatically sends a 103 Early Hints informational response with the specified Link header(s).
+ *
+ * @param options EarlyHintsOptions
+ * @returns MiddlewareHandler
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const earlyHints = (
+ options: EarlyHintsOptions
+): MiddlewareHandler => {
+ let warned = false
+
+ return async (c, next) => {
+ const mode = c.req.header('Sec-Fetch-Mode')
+ const dest = c.req.header('Sec-Fetch-Dest')
+
+ if ((mode && mode !== 'navigate') || (dest && dest !== 'document')) {
+ return next()
+ }
+
+ const env = c.env || {}
+ const bindings = (env.server ? env.server : env) as HttpBindings
+ const outgoing = bindings?.outgoing
+
+ // Capability check: outgoing.writeEarlyHints exists and is a function.
+ // This guard exists for non-Node runtimes and non-HTTP bindings.
+ if (typeof outgoing?.writeEarlyHints !== 'function') {
+ if (!warned) {
+ console.warn(
+ 'Early Hints Middleware is not supported because writeEarlyHints is not defined.'
+ )
+ warned = true
+ }
+ return await next()
+ }
+
+ if (!outgoing.headersSent) {
+ const link = typeof options.link === 'function' ? options.link(c) : options.link
+
+ if (link !== undefined && (Array.isArray(link) ? link.length > 0 : Boolean(link))) {
+ outgoing.writeEarlyHints({ link })
+ }
+ }
+
+ await next()
+ }
+}
diff --git a/test/early-hints.test.ts b/test/early-hints.test.ts
new file mode 100644
index 00000000..a6dd02a9
--- /dev/null
+++ b/test/early-hints.test.ts
@@ -0,0 +1,457 @@
+import type { Context, MiddlewareHandler } from 'hono'
+import { Hono } from 'hono'
+import { describe, it, expect, expectTypeOf, vi } from 'vitest'
+import http, { createServer } from 'node:http'
+import http2 from 'node:http2'
+import type { AddressInfo } from 'node:net'
+import { earlyHints } from '../src/early-hints'
+import { getRequestListener } from '../src/listener'
+
+describe('HTTP/1.1 Early Hints Middleware', () => {
+ it('should send a 103 early hints response before the final response using a string link', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ earlyHints({
+ link: '; rel=preload; as=style',
+ })
+ )
+ app.get('/', (c) => c.text('Hello Early Hints'))
+
+ const server = createServer(getRequestListener(app.fetch))
+
+ await new Promise((resolve, reject) => {
+ server.listen(0, () => {
+ const address = server.address() as AddressInfo
+ const port = address.port
+
+ const req = http.request(
+ {
+ port,
+ path: '/',
+ },
+ (res) => {
+ let body = ''
+ res.on('data', (chunk) => (body += chunk))
+ res.on('end', () => {
+ try {
+ expect(res.statusCode).toBe(200)
+ expect(body).toBe('Hello Early Hints')
+ expect(earlyHintReceived).toBe(true)
+ server.close((err) => (err ? reject(err) : resolve()))
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+ res.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ }
+ )
+
+ let earlyHintReceived = false
+ req.on('information', (info) => {
+ try {
+ expect(info.statusCode).toBe(103)
+ expect(info.headers.link).toBe('; rel=preload; as=style')
+ earlyHintReceived = true
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+
+ req.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ req.end()
+ })
+ })
+ })
+
+ it('should send early hints with an array of link headers', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ earlyHints({
+ link: ['; rel=preload; as=style', '; rel=preload; as=script'],
+ })
+ )
+ app.get('/', (c) => c.text('Hello Middleware'))
+
+ const server = createServer(getRequestListener(app.fetch))
+
+ await new Promise((resolve, reject) => {
+ server.listen(0, () => {
+ const address = server.address() as AddressInfo
+ const port = address.port
+
+ const req = http.request(
+ {
+ port,
+ path: '/',
+ },
+ (res) => {
+ let body = ''
+ res.on('data', (chunk) => (body += chunk))
+ res.on('end', () => {
+ try {
+ expect(res.statusCode).toBe(200)
+ expect(body).toBe('Hello Middleware')
+ expect(earlyHintReceived).toBe(true)
+ server.close((err) => (err ? reject(err) : resolve()))
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+ res.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ }
+ )
+
+ let earlyHintReceived = false
+ req.on('information', (info) => {
+ try {
+ expect(info.statusCode).toBe(103)
+ expect(info.headers.link).toBe(
+ '; rel=preload; as=style, ; rel=preload; as=script'
+ )
+ earlyHintReceived = true
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+
+ req.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ req.end()
+ })
+ })
+ })
+
+ it('should evaluate dynamic link function with Context', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ earlyHints({
+ link: (c) =>
+ c.req.query('theme') === 'dark'
+ ? '; rel=preload; as=style'
+ : '; rel=preload; as=style',
+ })
+ )
+ app.get('/', (c) => c.text('Hello Dynamic'))
+
+ const server = createServer(getRequestListener(app.fetch))
+
+ await new Promise((resolve, reject) => {
+ server.listen(0, () => {
+ const address = server.address() as AddressInfo
+ const port = address.port
+
+ const req = http.request(
+ {
+ port,
+ path: '/?theme=dark',
+ },
+ (res) => {
+ let body = ''
+ res.on('data', (chunk) => (body += chunk))
+ res.on('end', () => {
+ try {
+ expect(res.statusCode).toBe(200)
+ expect(body).toBe('Hello Dynamic')
+ expect(earlyHintReceived).toBe(true)
+ server.close((err) => (err ? reject(err) : resolve()))
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+ res.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ }
+ )
+
+ let earlyHintReceived = false
+ req.on('information', (info) => {
+ try {
+ expect(info.statusCode).toBe(103)
+ expect(info.headers.link).toBe('; rel=preload; as=style')
+ earlyHintReceived = true
+ } catch (err) {
+ server.close()
+ reject(err)
+ }
+ })
+
+ req.on('error', (err) => {
+ server.close()
+ reject(err)
+ })
+ req.end()
+ })
+ })
+ })
+})
+
+describe('HTTP/2 Early Hints Middleware', () => {
+ it('should send a 103 early hints response over HTTP/2', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ earlyHints({
+ link: '; rel=preload; as=style',
+ })
+ )
+ app.get('/', (c) => c.text('Hello HTTP2 Early Hints'))
+
+ const server = http2.createServer(getRequestListener(app.fetch))
+
+ await new Promise((resolve, reject) => {
+ server.listen(0, () => {
+ const address = server.address() as AddressInfo
+ const port = address.port
+
+ const client = http2.connect(`http://localhost:${port}`)
+ const req = client.request({ ':path': '/' })
+
+ let earlyHintReceived = false
+ req.on('headers', (headers) => {
+ try {
+ if (headers[':status'] === 103) {
+ expect(headers.link).toBe('; rel=preload; as=style')
+ earlyHintReceived = true
+ }
+ } catch (err) {
+ client.close()
+ server.close()
+ reject(err)
+ }
+ })
+
+ let finalResponseReceived = false
+ req.on('response', (headers) => {
+ try {
+ expect(headers[':status']).toBe(200)
+ finalResponseReceived = true
+ } catch (err) {
+ client.close()
+ server.close()
+ reject(err)
+ }
+ })
+
+ let body = ''
+ req.on('data', (chunk) => (body += chunk))
+
+ req.on('end', () => {
+ try {
+ expect(earlyHintReceived).toBe(true)
+ expect(finalResponseReceived).toBe(true)
+ expect(body).toBe('Hello HTTP2 Early Hints')
+ client.close()
+ server.close((err) => (err ? reject(err) : resolve()))
+ } catch (err) {
+ client.close()
+ server.close()
+ reject(err)
+ }
+ })
+
+ req.on('error', (err) => {
+ client.close()
+ server.close()
+ reject(err)
+ })
+ })
+ })
+ })
+})
+
+describe('Early Hints Middleware Fetch Metadata Filtering', () => {
+ const createContext = (mode?: string, dest?: string) => {
+ const writeEarlyHints = vi.fn()
+ const context = {
+ req: {
+ header: (name: string) => {
+ if (name === 'Sec-Fetch-Mode') {
+ return mode
+ }
+ if (name === 'Sec-Fetch-Dest') {
+ return dest
+ }
+ },
+ },
+ env: {
+ outgoing: {
+ writeEarlyHints,
+ headersSent: false,
+ },
+ },
+ } as unknown as Context
+
+ return { context, writeEarlyHints }
+ }
+
+ it.each([
+ ['both headers are missing', undefined, undefined],
+ ['both headers match', 'navigate', 'document'],
+ ['only the mode header matches', 'navigate', undefined],
+ ['only the destination header matches', undefined, 'document'],
+ ])('should send hints when %s', async (_description, mode, dest) => {
+ const { context, writeEarlyHints } = createContext(mode, dest)
+ const next = vi.fn().mockResolvedValue(undefined)
+ const middleware = earlyHints({
+ link: '; rel=preload; as=style',
+ })
+
+ await middleware(context, next)
+
+ expect(writeEarlyHints).toHaveBeenCalledWith({
+ link: '; rel=preload; as=style',
+ })
+ expect(next).toHaveBeenCalledTimes(1)
+ })
+
+ it.each([
+ ['the mode is cors', 'cors', undefined],
+ ['the destination is empty', undefined, 'empty'],
+ ['the destination is an iframe', 'navigate', 'iframe'],
+ ['the mode is no-cors', 'no-cors', 'document'],
+ ])('should skip hints when %s', async (_description, mode, dest) => {
+ const { context, writeEarlyHints } = createContext(mode, dest)
+ const next = vi.fn().mockResolvedValue(undefined)
+ const middleware = earlyHints({
+ link: '; rel=preload; as=style',
+ })
+
+ await middleware(context, next)
+
+ expect(writeEarlyHints).not.toHaveBeenCalled()
+ expect(next).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('Early Hints Middleware Unit & Edge Cases', () => {
+ it('should preserve the application Env type', () => {
+ type TestEnv = {
+ Bindings: {
+ theme: string
+ }
+ Variables: {
+ userId: string
+ }
+ }
+
+ const middleware = earlyHints({
+ link: (c) => {
+ expectTypeOf(c.env.theme).toEqualTypeOf()
+ expectTypeOf(c.get('userId')).toEqualTypeOf()
+ return undefined
+ },
+ })
+
+ expectTypeOf(middleware).toEqualTypeOf>()
+ })
+
+ it('should warn once per middleware instance when writeEarlyHints is unavailable', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const mockCtx = {
+ req: {
+ header: () => undefined,
+ },
+ env: {
+ outgoing: {
+ headersSent: false,
+ },
+ },
+ } as unknown as Context
+
+ const nextFn = vi.fn().mockResolvedValue(undefined)
+
+ const mw1 = earlyHints({ link: '/style.css' })
+ await mw1(mockCtx, nextFn)
+ await mw1(mockCtx, nextFn)
+
+ expect(consoleSpy).toHaveBeenCalledTimes(1)
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Early Hints Middleware is not supported because writeEarlyHints is not defined.'
+ )
+ expect(nextFn).toHaveBeenCalledTimes(2)
+
+ const mw2 = earlyHints({ link: '/style.css' })
+ await mw2(mockCtx, nextFn)
+
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
+ expect(nextFn).toHaveBeenCalledTimes(3)
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should no-op safely when headersSent is true', async () => {
+ const writeEarlyHintsMock = vi.fn()
+ const mockCtx = {
+ req: {
+ header: () => undefined,
+ },
+ env: {
+ outgoing: {
+ writeEarlyHints: writeEarlyHintsMock,
+ headersSent: true,
+ },
+ },
+ } as unknown as Context
+
+ const nextFn = vi.fn().mockResolvedValue(undefined)
+ const mw = earlyHints({ link: '/style.css' })
+
+ await mw(mockCtx, nextFn)
+
+ expect(writeEarlyHintsMock).not.toHaveBeenCalled()
+ expect(nextFn).toHaveBeenCalledTimes(1)
+ })
+
+ it('should skip sending hints when dynamic link function returns undefined or empty array', async () => {
+ const writeEarlyHintsMock = vi.fn()
+ const mockCtx = {
+ req: {
+ header: () => undefined,
+ },
+ env: {
+ outgoing: {
+ writeEarlyHints: writeEarlyHintsMock,
+ headersSent: false,
+ },
+ },
+ } as unknown as Context
+
+ const nextFn = vi.fn().mockResolvedValue(undefined)
+
+ // Undefined return
+ const mwUndefined = earlyHints({ link: () => undefined })
+ await mwUndefined(mockCtx, nextFn)
+
+ expect(writeEarlyHintsMock).not.toHaveBeenCalled()
+ expect(nextFn).toHaveBeenCalledTimes(1)
+
+ // Empty array return
+ const mwEmptyArray = earlyHints({ link: () => [] })
+ await mwEmptyArray(mockCtx, nextFn)
+
+ expect(writeEarlyHintsMock).not.toHaveBeenCalled()
+ expect(nextFn).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 7b79a97c..4f771fbe 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
- entry: ['./src/index.ts', './src/serve-static.ts', './src/conninfo.ts', './src/utils/*.ts'],
+ entry: ['./src/index.ts', './src/serve-static.ts', './src/conninfo.ts', './src/early-hints.ts', './src/utils/*.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: false,