From 4494e3374824a5ee20638af51324d79d7d7cf61e Mon Sep 17 00:00:00 2001 From: Bilal Azam Date: Sun, 19 Jul 2026 00:53:28 +0500 Subject: [PATCH 1/6] (feat): add writeEarlyHints helper for HTTP 103 Early Hints support --- README.md | 56 ++++++++++ package.json | 13 +++ src/early-hints.ts | 51 +++++++++ src/index.ts | 2 + test/early-hints.test.ts | 231 +++++++++++++++++++++++++++++++++++++++ tsdown.config.ts | 2 +- 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 src/early-hints.ts create mode 100644 test/early-hints.test.ts diff --git a/README.md b/README.md index 7116eabe..ce738a42 100644 --- a/README.md +++ b/README.md @@ -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: [ + '; rel=preload; as=style', + '; rel=preload; as=script' + ] + }) + + // Long-running or async operation to generate the main page response + return c.html('

Hello Hono!

') +}) + +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: '; rel=preload; as=style' + }) +) + +app.get('/', (c) => { + return c.html('

Hello Hono!

') +}) + +serve(app) +``` + ## 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 ba1b7ac1..8805e761 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..3d6c55a8 --- /dev/null +++ b/src/early-hints.ts @@ -0,0 +1,51 @@ +import type { Context, MiddlewareHandler } from 'hono' + +export type EarlyHintHeaderValue = string | string[] +export type EarlyHints = Record + +/** + * 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() + } +} diff --git a/src/index.ts b/src/index.ts index d41b69a4..b909d337 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/test/early-hints.test.ts b/test/early-hints.test.ts new file mode 100644 index 00000000..fea2ba9a --- /dev/null +++ b/test/early-hints.test.ts @@ -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: '; rel=preload; as=style', + }) + expect(hintsWritten).toBe(true) + return c.text('Hello Early Hints') + }) + + const server = createServer(getRequestListener(app.fetch)) + + await new Promise((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('; 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: ['; 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 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('; 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 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: '; 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((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('; 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) + }) + }) + }) + }) +}) 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, From f5ecffb7488cfd75ed46cdd925cfd0c62b0ee6a3 Mon Sep 17 00:00:00 2001 From: Bilal Azam Date: Sun, 26 Jul 2026 06:20:41 +0500 Subject: [PATCH 2/6] 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. --- README.md | 49 +++--- src/early-hints.ts | 72 ++++----- src/index.ts | 2 - test/early-hints.test.ts | 316 +++++++++++++++++++++++++++------------ 4 files changed, 276 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index ce738a42..70af55dd 100644 --- a/README.md +++ b/README.md @@ -332,60 +332,49 @@ type Http2Bindings = { } ``` -## Early Hints Helper & Middleware +## Early Hints 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). +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). -### Using the Helper +### Usage -Import `writeEarlyHints` and call it inside your handler: +Import `earlyHints` from `@hono/node-server/early-hints`: + +#### Static links ```ts import { serve } from '@hono/node-server' -import { writeEarlyHints } from '@hono/node-server/early-hints' +import { earlyHints } from '@hono/node-server/early-hints' import { Hono } from 'hono' const app = new Hono() -app.get('/', (c) => { - // Preload hints sent immediately - writeEarlyHints(c, { - link: [ - '; rel=preload; as=style', - '; rel=preload; as=script' - ] +app.use( + '*', + earlyHints({ + link: '; rel=preload; as=style' }) +) - // Long-running or async operation to generate the main page response +app.get('/', (c) => { return c.html('

Hello Hono!

') }) serve(app) ``` -### Using the Middleware - -You can also use the `earlyHints` middleware to automatically send Early Hints: +#### Dynamic 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' + link: (c) => + c.req.query('theme') === 'dark' + ? '; rel=preload; as=style' + : '; rel=preload; as=style' }) ) - -app.get('/', (c) => { - return c.html('

Hello Hono!

') -}) - -serve(app) ``` ## Direct response from Node.js API diff --git a/src/early-hints.ts b/src/early-hints.ts index 3d6c55a8..c18fd965 100644 --- a/src/early-hints.ts +++ b/src/early-hints.ts @@ -1,51 +1,45 @@ import type { Context, MiddlewareHandler } from 'hono' +import type { HttpBindings } from './types' -export type EarlyHintHeaderValue = string | string[] -export type EarlyHints = Record - -/** - * 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 +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 headers. - * - * @param hints Early Hints headers + * Automatically sends a 103 Early Hints informational response with the specified Link header(s). + * + * @param options EarlyHintsOptions * @returns MiddlewareHandler */ -export const earlyHints = (hints: EarlyHints): MiddlewareHandler => { +export const earlyHints = (options: EarlyHintsOptions): MiddlewareHandler => { + let warned = false + return async (c, next) => { - writeEarlyHints(c, hints) + 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/src/index.ts b/src/index.ts index b909d337..d41b69a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,5 @@ 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' diff --git a/test/early-hints.test.ts b/test/early-hints.test.ts index fea2ba9a..d44d0dfd 100644 --- a/test/early-hints.test.ts +++ b/test/early-hints.test.ts @@ -1,51 +1,55 @@ +import type { Context } from 'hono' 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 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', () => { - it('should send a 103 early hints response before the final response', async () => { +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.get('/', (c) => { - const hintsWritten = writeEarlyHints(c, { + app.use( + '*', + earlyHints({ link: '; rel=preload; as=style', }) - expect(hintsWritten).toBe(true) - return c.text('Hello Early Hints') - }) + ) + 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 any + 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) { + 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) - } - }) - res.on('error', (err) => { - server.close() - reject(err) - }) - }) + }) + } + ) let earlyHintReceived = false req.on('information', (info) => { @@ -68,49 +72,56 @@ describe('HTTP/1.1 Early Hints', () => { }) }) - it('should send early hints using middleware', async () => { + 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.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 any + 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) { + 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) - } - }) - 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('; rel=preload; as=style, ; rel=preload; as=script') + expect(info.headers.link).toBe( + '; rel=preload; as=style, ; rel=preload; as=script' + ) earlyHintReceived = true } catch (err) { server.close() @@ -127,51 +138,90 @@ describe('HTTP/1.1 Early Hints', () => { }) }) - it('should return false if writeEarlyHints is absent on the outgoing message', () => { - const mockCtx = { - env: { - outgoing: { - headersSent: false, - } - } - } as any + 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 result = writeEarlyHints(mockCtx, { link: '/style.css' }) - expect(result).toBe(false) - }) + const server = createServer(getRequestListener(app.fetch)) - it('should return false if headers are already sent', () => { - const mockCtx = { - env: { - outgoing: { - writeEarlyHints: vi.fn(), - headersSent: true, - } - } - } as any + 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) + } + }) - const result = writeEarlyHints(mockCtx, { link: '/style.css' }) - expect(result).toBe(false) - expect(mockCtx.env.outgoing.writeEarlyHints).not.toHaveBeenCalled() + req.on('error', (err) => { + server.close() + reject(err) + }) + req.end() + }) + }) }) }) -describe('HTTP/2 Early Hints', () => { - it('should send a 103 early hints response before the final response over HTTP/2', async () => { +describe('HTTP/2 Early Hints Middleware', () => { + it('should send a 103 early hints response over HTTP/2', async () => { const app = new Hono() - app.get('/', (c) => { - const hintsWritten = writeEarlyHints(c, { + app.use( + '*', + earlyHints({ link: '; rel=preload; as=style', }) - expect(hintsWritten).toBe(true) - return c.text('Hello HTTP2 Early Hints') - }) + ) + 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 any + const address = server.address() as AddressInfo const port = address.port const client = http2.connect(`http://localhost:${port}`) @@ -204,7 +254,7 @@ describe('HTTP/2 Early Hints', () => { }) let body = '' - req.on('data', chunk => body += chunk) + req.on('data', (chunk) => (body += chunk)) req.on('end', () => { try { @@ -212,7 +262,7 @@ describe('HTTP/2 Early Hints', () => { expect(finalResponseReceived).toBe(true) expect(body).toBe('Hello HTTP2 Early Hints') client.close() - server.close(err => err ? reject(err) : resolve()) + server.close((err) => (err ? reject(err) : resolve())) } catch (err) { client.close() server.close() @@ -229,3 +279,85 @@ describe('HTTP/2 Early Hints', () => { }) }) }) + +describe('Early Hints Middleware Unit & Edge Cases', () => { + it('should warn once per middleware instance when writeEarlyHints is unavailable', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const mockCtx = { + 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 = { + 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 = { + 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) + }) +}) From c856c7d94de977493a61d6b1cc83d44e6f634413 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jul 2026 12:03:05 +0900 Subject: [PATCH 3/6] fix(early-hints): preserve middleware env types --- src/early-hints.ts | 11 +++++++---- test/early-hints.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/early-hints.ts b/src/early-hints.ts index c18fd965..368f3d5e 100644 --- a/src/early-hints.ts +++ b/src/early-hints.ts @@ -1,8 +1,8 @@ -import type { Context, MiddlewareHandler } from 'hono' +import type { Context, Env, MiddlewareHandler } from 'hono' import type { HttpBindings } from './types' -export type EarlyHintsOptions = { - link: string | string[] | ((c: Context) => string | string[] | undefined) +export type EarlyHintsOptions = { + link: string | string[] | ((c: Context) => string | string[] | undefined) } /** @@ -12,7 +12,10 @@ export type EarlyHintsOptions = { * @param options EarlyHintsOptions * @returns MiddlewareHandler */ -export const earlyHints = (options: EarlyHintsOptions): MiddlewareHandler => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const earlyHints = ( + options: EarlyHintsOptions +): MiddlewareHandler => { let warned = false return async (c, next) => { diff --git a/test/early-hints.test.ts b/test/early-hints.test.ts index d44d0dfd..c3ccb614 100644 --- a/test/early-hints.test.ts +++ b/test/early-hints.test.ts @@ -1,6 +1,6 @@ -import type { Context } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' import { Hono } from 'hono' -import { describe, it, expect, vi } from 'vitest' +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' @@ -281,6 +281,27 @@ describe('HTTP/2 Early Hints Middleware', () => { }) 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(() => {}) From 3be88279a48f2a0cf1e4fcfda0e670d3f611bd2c Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jul 2026 12:03:30 +0900 Subject: [PATCH 4/6] docs: simplify early hints middleware usage --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 70af55dd..9bb7508b 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,6 @@ import { Hono } from 'hono' const app = new Hono() app.use( - '*', earlyHints({ link: '; rel=preload; as=style' }) @@ -367,7 +366,6 @@ serve(app) ```ts app.use( - '*', earlyHints({ link: (c) => c.req.query('theme') === 'dark' From ddaa5652b7167bed3495457fe2807efbc20ad9b4 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jul 2026 12:05:37 +0900 Subject: [PATCH 5/6] feat(early-hints): filter non-document requests --- src/early-hints.ts | 7 ++++ test/early-hints.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/early-hints.ts b/src/early-hints.ts index 368f3d5e..ecbf026e 100644 --- a/src/early-hints.ts +++ b/src/early-hints.ts @@ -19,6 +19,13 @@ export const earlyHints = ( 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 diff --git a/test/early-hints.test.ts b/test/early-hints.test.ts index c3ccb614..a6dd02a9 100644 --- a/test/early-hints.test.ts +++ b/test/early-hints.test.ts @@ -280,6 +280,70 @@ describe('HTTP/2 Early Hints Middleware', () => { }) }) +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 = { @@ -306,6 +370,9 @@ describe('Early Hints Middleware Unit & Edge Cases', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const mockCtx = { + req: { + header: () => undefined, + }, env: { outgoing: { headersSent: false, @@ -337,6 +404,9 @@ describe('Early Hints Middleware Unit & Edge Cases', () => { it('should no-op safely when headersSent is true', async () => { const writeEarlyHintsMock = vi.fn() const mockCtx = { + req: { + header: () => undefined, + }, env: { outgoing: { writeEarlyHints: writeEarlyHintsMock, @@ -357,6 +427,9 @@ describe('Early Hints Middleware Unit & Edge Cases', () => { 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, From c26e0738d3795364a6befdf4e7344b6454f94f09 Mon Sep 17 00:00:00 2001 From: Bilal Azam Date: Mon, 27 Jul 2026 18:20:30 +0500 Subject: [PATCH 6/6] docs: note Sec-Fetch filtering behaviour for Early Hints --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9bb7508b..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 }, @@ -351,7 +351,7 @@ const app = new Hono() app.use( earlyHints({ - link: '; rel=preload; as=style' + link: '; rel=preload; as=style', }) ) @@ -370,11 +370,14 @@ app.use( link: (c) => c.req.query('theme') === 'dark' ? '; rel=preload; as=style' - : '; 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.