Skip to content

feat: add Early Hints (HTTP 103) middleware - #378

Open
bilal-azam wants to merge 8 commits into
honojs:mainfrom
bilal-azam:feat/add-write-early-hints-helper
Open

feat: add Early Hints (HTTP 103) middleware#378
bilal-azam wants to merge 8 commits into
honojs:mainfrom
bilal-azam:feat/add-write-early-hints-helper

Conversation

@bilal-azam

@bilal-azam bilal-azam commented Jul 18, 2026

Copy link
Copy Markdown

Summary

Adds Early Hints (HTTP 103) support at the runtime layer, following @yusukebe's
direction in honojs/hono#5046 that this belongs in the runtime adapter rather
than Hono core.

Exposes an earlyHints middleware from @hono/node-server/early-hints that
sends preload/preconnect Link headers before the handler's response is ready,
using Node's native response.writeEarlyHints() via the existing
HttpBindings.

import { earlyHints } from '@hono/node-server/early-hints'

// static
app.get('/', earlyHints({ link: '</styles.css>; rel=preload; as=style' }), (c) =>
  c.html(renderPage())
)

// dynamic
app.get(
  '/',
  earlyHints({
    link: (c) => `</themes/${c.req.query('theme')}.css>; rel=preload; as=style`,
  }),
  (c) => c.html(renderPage())
)

API

link accepts string | string[] | ((c: Context) => string | string[] | undefined).
Returning undefined or an empty array skips sending hints.

Behaviour

  • Opt-in middleware; no changes to the existing response flow
  • Exposed only from the ./early-hints subpath, consistent with serve-static
    and conninfo — nothing added to the root entry
  • Warns once per middleware instance when outgoing.writeEarlyHints is
    unavailable, then continues as a no-op (matching the Cache middleware's
    unavailable-runtime behaviour). This guard covers non-Node runtimes and
    non-HTTP bindings — writeEarlyHints has existed since Node 18.11 and this
    package requires Node >= 20
  • No-ops safely when response headers have already been sent
  • Sends hints only for likely document navigations, based on Sec-Fetch-Mode
    and Sec-Fetch-Dest; requests missing either header are allowed through, so
    non-browser clients still work

Tests

  • HTTP/1.1 and HTTP/2 integration tests: 103 with Link observed on the wire
    before the final response
  • Dynamic link function receives the Context and its result is sent
  • Warn-once: two requests through one middleware instance produce one warning;
    a separate instance warns again
  • No-op paths: headers already sent; undefined/empty return
  • Final response status, headers and body unaffected in all cases
  • Fetch metadata filtering matrix (headers absent, both matching, mode-only,
    dest-only) and an Env preservation type assertion

Changes from the initial version

Reworked per @usualoma's review: removed the public writeEarlyHints helper and
the root re-export in favour of a middleware-only API on the ./early-hints
subpath, flattened the options, and added dynamic-link and warn-once
behaviour with tests.

Ref honojs/hono#5046.

@usualoma

Copy link
Copy Markdown
Member

Hi @bilal-azam,

Thanks for working on this. I think the idea of supporting Early Hints as middleware makes sense. That said, my preference would be to keep the public API narrower:

  • I would avoid re-exporting this from the root entry point and expose it only from @hono/node-server/early-hints, consistent with subpath APIs such as serve-static and conninfo.
  • I’m also not convinced that writeEarlyHints should be public. It feels like a very thin abstraction over c.env.outgoing.writeEarlyHints(). Users who need imperative access can already use HttpBindings directly.
  • Regarding the middleware options, I agree with the current shape of accepting link directly. An API such as earlyHints({ hints: { link: '...' } }) could also be clean and more extensible, but in practice Link is likely to be the only header users need for Early Hints. The flatter API seems simpler and preferable:
  earlyHints({
    link: '</styles.css>; rel=preload; as=style',
  })

  earlyHints({
    link: (c) =>
      `</themes/${c.req.query('theme')}.css>; rel=preload; as=style`,
  })

Allowing string | string[] or a context-based function for link would cover both static and dynamic cases while staying consistent with other Hono middleware options.

I would also expect the middleware to warn only once per middleware instance when outgoing.writeEarlyHints is unavailable, then continue as a no-op. This would be similar to the unavailable-runtime behavior of Hono’s Cache middleware. It should also safely no-op if the response headers have already been sent.

The existing HTTP/1.1 and HTTP/2 integration tests look valuable and should mostly remain reusable. They would mainly need to be adapted to the middleware-only API, with coverage added for a dynamic link and the warn-once behavior.

@bilal-azam

Copy link
Copy Markdown
Author

Thanks @usualoma, this all makes sense, and I agree with narrowing the surface. I'll rework it as middleware-only:

  • Removing writeEarlyHints from the public API entirely, and dropping the root re-export, exposed only from @hono/node-server/early-hints, matching serve-static and conninfo.
  • Flat options with link: string | string[] | ((c: Context) => string | string[] | undefined), so both static and dynamic cases work:
earlyHints({ link: '</styles.css>; rel=preload; as=style' })
earlyHints({ link: (c) => `</themes/${c.req.query('theme')}.css>; rel=preload; as=style` })
  • Warn-once per middleware instance when outgoing.writeEarlyHints isn't available, then continue as a no-op, I'll follow the Cache middleware's pattern for the message and shape.
  • Safe no-op when headers have already been sent.
  • Keeping the HTTP/1.1 and HTTP/2 integration tests, adapted to the middleware API, plus coverage for a dynamic link and for warn-once.

One question on the dynamic form: if the function returns undefined (or an empty array), I'm planning to skip sending hints entirely rather than emit an empty Link, let me know if you'd prefer different behaviour there. I'll push the rework shortly.

bilal-azam and others added 4 commits July 26, 2026 06:20
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.
@usualoma

Copy link
Copy Markdown
Member

Hi @bilal-azam,
Thanks for the update. I think skipping Early Hints when the function returns undefined or an empty array is the right behavior.

I also opened bilal-azam#1 with a few follow-up changes. Could you consider merging it into this PR?

  • Preserve the application Env type in the middleware, consistently with serveStatic.
  • Simplify the README examples by omitting the redundant '*' argument from app.use().
  • Send Early Hints only for likely document navigations based on Sec-Fetch-Mode and Sec-Fetch-Dest, while allowing requests where either header is absent.

@bilal-azam

Copy link
Copy Markdown
Author

Thanks @usualoma, merged. The Sec-Fetch filtering is a good call; sending hints on fetch/XHR requests would just be wasted bytes, and failing open when the headers are absent keeps non-browser clients working. Good catch on the env.server unwrapping too. I'd missed that binding shape, and the Env generic makes the link callback properly typed for apps with custom bindings.

Tests, build, and lint all pass locally after the merge, and I've updated the PR description to cover the new filtering behaviour.

One small question: should the README mention the Sec-Fetch-Mode / Sec-Fetch-Dest filtering? Someone testing with fetch() or curl might wonder why no 103 appears. Happy to add a sentence if you think it's worth documenting.

@usualoma

Copy link
Copy Markdown
Member

Hi @bilal-azam,
Thank you for accepting my proposal.

One small question: should the README mention the Sec-Fetch-Mode / Sec-Fetch-Dest filtering? Someone testing with fetch() or curl might wonder why a 103 status code doesn't appear. I'd be happy to add a sentence if you think it's worth documenting.

First of all, when using curl or the fetch API (which is the default in JavaScript runtimes like Node.js), Sec-Fetch-Mode and Sec-Fetch-Dest aren’t included in the request headers, so a 103 response will be sent. That said, there might be situations where people wonder “why.” I think it would be a good idea to add a brief note to the documentation.

@bilal-azam

Copy link
Copy Markdown
Author

You're right; they don't send Sec-Fetch-* at all, so they fall through the "allow when absent" branch and do receive a 103. The confusing case is the opposite of what I had in mind. I've added a short note to the README covering both directions.

@usualoma

Copy link
Copy Markdown
Member

Hi @bilal-azam,
Thank you!

Hi @yusukebe,
I think it's okay to merge this, but what do you think?

@bilal-azam bilal-azam changed the title feat: add writeEarlyHints helper for HTTP 103 Early Hints support feat: add Early Hints (HTTP 103) middleware Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants