Skip to content

fix(writeEarlyHints): normalize Link key to prevent hanging with Node.js#1385

Merged
pi0 merged 5 commits into
h3js:mainfrom
mixelburg:fix/writeEarlyHints-normalize-link
Jul 2, 2026
Merged

fix(writeEarlyHints): normalize Link key to prevent hanging with Node.js#1385
pi0 merged 5 commits into
h3js:mainfrom
mixelburg:fix/writeEarlyHints-normalize-link

Conversation

@mixelburg

@mixelburg mixelburg commented May 11, 2026

Copy link
Copy Markdown
Contributor

What

Fixes writeEarlyHints never resolving (hanging indefinitely) when called with a Link header using a non-lowercase key (e.g., { Link: '...' }).

Closes #1383.

Why

Node.js's native res.writeEarlyHints(hints, cb) only reads hints.link (lowercase). If link is missing or undefined, it returns early without invoking the callback. Since HTTP headers are case-insensitive, users naturally pass { Link: '...' } — causing the h3 function to hang forever because the Promise never resolves.

How

Before calling the native writeEarlyHints:

  1. Normalize any Link / LINK / etc. key to lowercase link
  2. If no link property exists after normalization, return immediately (no need for a Promise)

Reproduction

// This hangs forever before the fix:
const app = new H3().get("/", async event => {
  await writeEarlyHints(event, { Link: "</style.css>; rel=preload; as=style" });
  return "ok";
});

Root Cause

Node.js source (lib/_http_server.js):

function writeEarlyHints(hints, cb) {
  if (hints.link === null || hints.link === undefined) {
    return; // returns without calling cb!
  }
  // ...
  this._writeRaw(head, 'ascii', cb);
}

Summary by CodeRabbit

  • Bug Fixes
    • Improved early hints handling by normalizing link/Link variations, dropping falsy/empty entries, and preventing hangs when no valid link hints are provided. The CDN/web fallback now emits only the collected, truthy link header values.
  • Tests
    • Added regression coverage to ensure writeEarlyHints resolves promptly (including {} and empty link/Link cases), and verified correct header output when mixed values are provided and when both link and Link must be merged.

@mixelburg
mixelburg requested a review from pi0 as a code owner May 11, 2026 22:21
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 208ebbd3-ea73-4b06-b637-e2e0b6477e48

📥 Commits

Reviewing files that changed from the base of the PR and between 268aab1 and 56be845.

📒 Files selected for processing (1)
  • src/utils/response.ts

📝 Walkthrough

Walkthrough

writeEarlyHints() now normalizes link/Link hints, skips the Node path when no usable link values remain, and the test suite adds regressions for empty, capitalized, and merged hint inputs.

Changes

Early-hints callback normalization

Layer / File(s) Summary
Node.js early-hints normalization and guard
src/utils/response.ts
writeEarlyHints() collects case-insensitive link values, drops falsy entries, returns early when none remain, and forwards normalized hints to the Node runtime or fallback headers.
writeEarlyHints regression tests
test/utils.test.ts
Tests add timeout-raced coverage for empty hints, empty/capitalized link inputs, merged link/Link inputs, and fallback header emission.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • h3js/h3#1288: Also changes writeEarlyHints and the Link header fallback behavior.

Suggested reviewers: pi0

Related issues: #1383 writeEarlyHints never resolves with node runtime

Poem

🐰 I hop through hints both soft and bright,
link and Link now merge just right.
No sleepy hangs, no empty trace,
Early hints land in their place.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: normalizing Link keys to prevent a Node.js hang.
Linked Issues check ✅ Passed The changes address #1383 by resolving the Node hang on empty hints and handling Link keys case-insensitively.
Out of Scope Changes check ✅ Passed The added fallback normalization and regression tests are directly tied to the writeEarlyHints hang fix.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/response.ts`:
- Around line 121-125: The loop in src/utils/response.ts currently clobbers
prior Link values when normalizing keys: for case where name.toLowerCase() ===
"link" and name !== "link" you should merge the new value into
normalizedHints.link instead of overwriting it; locate the for (const [name,
value] of Object.entries(hints)) block and change the assignment to
append/concatenate the incoming value to normalizedHints.link (create an array
or comma-joined string if needed) while still deleting the original case-variant
key so all case-variant Link hints are preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8f62ae7-7197-4661-b16b-3c5c8040e4e1

📥 Commits

Reviewing files that changed from the base of the PR and between 84244b4 and d68edbf.

📒 Files selected for processing (1)
  • src/utils/response.ts

Comment thread src/utils/response.ts
Node.js writeEarlyHints only reads hints.link (lowercase) and returns
early without invoking the callback when the 'link' property is missing.
Since HTTP headers are case-insensitive, callers may pass 'Link' (capital L),
causing the native method to silently return and the promise to never resolve.

Fix: normalize the 'link' key before calling the native writeEarlyHints,
and return immediately (no promise) when no link hints are present.

Fixes h3js#1383
@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Note: this analysis was performed by an AI agent (Claude Code) on behalf of the maintainer.

This looks like the most correct fix for the writeEarlyHints hanging bug (#1383) among the three open PRs addressing it (this one, #1387, #1415) — it addresses the actual root cause (Node's native writeEarlyHints doesn't invoke its callback when the link key is missing/empty) rather than unconditionally dropping the await, so it preserves the "wait until flushed" guarantee for the success path. I've closed #1387 and #1415 as superseded by this one.

Would you be open to adding a regression test that reproduces the exact #1383 case (writeEarlyHints(event, {}))? That would make this easy to merge with confidence.

The previous fix normalized the Link key but shipped without test
coverage for the Node.js native path it targets (the existing test was
skipIf(target === 'node')). Add matrix regression tests that race
writeEarlyHints against a timer inside a real Node handler, proving the
promise resolves promptly instead of hanging when the resolved link
value is missing or empty.

Also harden the normalization to merge every case-variant of the link
key (e.g. both 'link' and 'Link') into a single value instead of letting
the last-processed key clobber the others, and to treat empty strings /
empty arrays as 'no link' (matching Node's own callback-skipping
behavior) so those inputs no longer hang.

Co-Authored-By: Claude Opus <[email protected]>
@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Pushed 1e43dbb to this branch on behalf of the maintainer.

Disclosure: this commit was authored by an AI agent (Claude Code / Claude Opus), not a human, and pushed via the maintainer-can-modify permission on this PR. Please review it as you would any change.

Thanks @mixelburg for the accurate root-cause diagnosis — that Node's native res.writeEarlyHints() reads the exact lowercase link key and returns without invoking its callback when the resolved link value is missing/empty, leaving h3's wrapping promise (and the request) pending forever. That analysis is what made the fix straightforward.

What this commit adds on top of your fix:

  1. The regression test that was requested. The existing writeEarlyHints test was skipIf(target === "node"), so the exact Node path this PR fixes had zero coverage. The new tests run in the describeMatrix node target and race writeEarlyHints against a short timer inside a real Node handler, so a hang surfaces as "timeout" rather than silently passing. I verified they are non-tautological: against the pre-fix code they fail with expected 'timeout' to be 'resolved' for empty hints, empty link value, and a capital-only Link key; against the fix they pass.

  2. A small hardening of the normalization (addressing the double-key point CodeRabbit raised): every case-variant of link (e.g. both link and Link) is now merged into a single value instead of the last-processed key clobbering the others, and empty strings / empty arrays are treated as "no link" (matching Node's own callback-skipping behavior) so those inputs no longer hang either.

pnpm vitest run test/utils.test.ts (115 passed) and pnpm lint (0 errors) are green.

pi0 and others added 3 commits July 2, 2026 15:13
The empty-value filter and case-insensitive link normalization added for
the Node native path were not applied to the web/CDN fallback, so falsy
link values appended a malformed empty `Link:` header on non-Node runtimes.

Share a single normalization step between both paths, drop falsy link
values in the fallback, and return a resolved Promise (not sync undefined)
on the Node empty-link early return for consistency. Add fallback tests
that assert the resulting Link header.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Only construct the normalized hints object on the Node path where it is
actually passed to the native writeEarlyHints; the CDN/web fallback now
uses the collected link values directly without allocating a discarded map.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@pi0
pi0 merged commit 2a164f3 into h3js:main Jul 2, 2026
6 of 7 checks passed
pi0x pushed a commit to mixelburg/h3 that referenced this pull request Jul 2, 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.

writeEarlyHints never resolves with node runtime

3 participants