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,