Skip to content
Open
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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,62 @@ type Http2Bindings = {
}
```

## Early Hints Helper & Middleware

You can send HTTP 103 Early Hints to instruct browsers to preload or preconnect resources before the final response is prepared. Both the helper function and the middleware sugar are supported under Node.js bindings (HTTP/1.1 and HTTP/2).

### Using the Helper

Import `writeEarlyHints` and call it inside your handler:

```ts
import { serve } from '@hono/node-server'
import { writeEarlyHints } from '@hono/node-server/early-hints'
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
// Preload hints sent immediately
writeEarlyHints(c, {
link: [
'</styles.css>; rel=preload; as=style',
'</script.js>; rel=preload; as=script'
]
})

// Long-running or async operation to generate the main page response
return c.html('<!DOCTYPE html><html><body><h1>Hello Hono!</h1></body></html>')
})

serve(app)
```

### Using the Middleware

You can also use the `earlyHints` middleware to automatically send Early Hints:

```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: '</styles.css>; rel=preload; as=style'
})
)

app.get('/', (c) => {
return c.html('<!DOCTYPE html><html><body><h1>Hello Hono!</h1></body></html>')
})

serve(app)
```

## Direct response from Node.js API

You can directly respond to the client from the Node.js API.
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -63,6 +73,9 @@
],
"conninfo": [
"./dist/conninfo.d.mts"
],
"early-hints": [
"./dist/early-hints.d.mts"
]
}
},
Expand Down
51 changes: 51 additions & 0 deletions src/early-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Context, MiddlewareHandler } from 'hono'

export type EarlyHintHeaderValue = string | string[]
export type EarlyHints = Record<string, EarlyHintHeaderValue>

/**
* Early Hints helper for Node.js
* Sends a 103 Early Hints informational response to the client with the specified headers.
*
* @param c Hono Context
* @param hints Early Hints headers (typically Link headers)
* @returns boolean indicating if the early hints were successfully written
*/
export const writeEarlyHints = (c: Context, hints: EarlyHints): boolean => {
const env = c.env || {}
const bindings = env.server ? env.server : env
const outgoing = bindings.outgoing

// Guard (a): c.env.outgoing exists and exposes writeEarlyHints as a function
// Callers on other runtimes or older Node versions must not crash and will get false
if (!outgoing || typeof outgoing.writeEarlyHints !== 'function') {
return false
}

// Guard (b): headersSent is false (informational responses cannot be sent after headers are sent)
if (outgoing.headersSent) {
return false
}

// Guard (c): HTTP/2 binding support verified.
// Both http.ServerResponse and http2.Http2ServerResponse support writeEarlyHints in Node.js >= 20.
// Reference Node.js documentation:
// - http: https://nodejs.org/api/http.html#responsewriteearlyhintshints
// - http2: https://nodejs.org/api/http2.html#responsewriteearlyhintshints
outgoing.writeEarlyHints(hints)
return true
}

/**
* Early Hints middleware for Node.js
* Automatically sends a 103 Early Hints informational response with the specified headers.
*
* @param hints Early Hints headers
* @returns MiddlewareHandler
*/
export const earlyHints = (hints: EarlyHints): MiddlewareHandler => {
return async (c, next) => {
writeEarlyHints(c, hints)
await next()
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ export { serve, createAdaptorServer } from './server'
export { upgradeWebSocket } from './websocket'
export { getRequestListener } from './listener'
export { RequestError } from './request'
export { writeEarlyHints, earlyHints } from './early-hints'
export type { HttpBindings, Http2Bindings, ServerType } from './types'
export type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types'
export type { EarlyHintHeaderValue, EarlyHints } from './early-hints'
231 changes: 231 additions & 0 deletions test/early-hints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { Hono } from 'hono'
import { createServer } from 'node:http'
import http from 'node:http'
import http2 from 'node:http2'
import { describe, it, expect, vi } from 'vitest'
import { writeEarlyHints, earlyHints } from '../src/early-hints'
import { getRequestListener } from '../src/listener'

describe('HTTP/1.1 Early Hints', () => {
it('should send a 103 early hints response before the final response', async () => {
const app = new Hono()
app.get('/', (c) => {
const hintsWritten = writeEarlyHints(c, {
link: '</style.css>; rel=preload; as=style',
})
expect(hintsWritten).toBe(true)
return c.text('Hello Early Hints')
})

const server = createServer(getRequestListener(app.fetch))

await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
const address = server.address() as any
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('</style.css>; 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 using middleware', async () => {
const app = new Hono()
app.use('*', earlyHints({
link: ['</style.css>; rel=preload; as=style', '</script.js>; rel=preload; as=script'],
}))
app.get('/', (c) => c.text('Hello Middleware'))

const server = createServer(getRequestListener(app.fetch))

await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
const address = server.address() as any
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)
// In HTTP/1.x, multiple headers or array headers get combined into a single comma-separated string.
expect(info.headers.link).toBe('</style.css>; rel=preload; as=style, </script.js>; rel=preload; as=script')
earlyHintReceived = true
} catch (err) {
server.close()
reject(err)
}
})

req.on('error', (err) => {
server.close()
reject(err)
})
req.end()
})
})
})

it('should return false if writeEarlyHints is absent on the outgoing message', () => {
const mockCtx = {
env: {
outgoing: {
headersSent: false,
}
}
} as any

const result = writeEarlyHints(mockCtx, { link: '/style.css' })
expect(result).toBe(false)
})

it('should return false if headers are already sent', () => {
const mockCtx = {
env: {
outgoing: {
writeEarlyHints: vi.fn(),
headersSent: true,
}
}
} as any

const result = writeEarlyHints(mockCtx, { link: '/style.css' })
expect(result).toBe(false)
expect(mockCtx.env.outgoing.writeEarlyHints).not.toHaveBeenCalled()
})
})

describe('HTTP/2 Early Hints', () => {
it('should send a 103 early hints response before the final response over HTTP/2', async () => {
const app = new Hono()
app.get('/', (c) => {
const hintsWritten = writeEarlyHints(c, {
link: '</style.css>; rel=preload; as=style',
})
expect(hintsWritten).toBe(true)
return c.text('Hello HTTP2 Early Hints')
})

const server = http2.createServer(getRequestListener(app.fetch))

await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
const address = server.address() as any
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('</style.css>; 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)
})
})
})
})
})
2 changes: 1 addition & 1 deletion tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading