From 4494e3374824a5ee20638af51324d79d7d7cf61e Mon Sep 17 00:00:00 2001 From: Bilal Azam Date: Sun, 19 Jul 2026 00:53:28 +0500 Subject: [PATCH] (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 7116eab..ce738a4 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 ba1b7ac..8805e76 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 0000000..3d6c55a --- /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 d41b69a..b909d33 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 0000000..fea2ba9 --- /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 7b79a97..4f771fb 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,