Skip to content

feat: add HTTP/2 multiplexing support for Node.js#4213

Open
maxleonard wants to merge 3 commits intomainfrom
feat/http2-support
Open

feat: add HTTP/2 multiplexing support for Node.js#4213
maxleonard wants to merge 3 commits intomainfrom
feat/http2-support

Conversation

@maxleonard
Copy link

Summary

Add HTTP/2 multiplexing support to the Node.js authentication package, enabling all requests to the same origin to share a single TCP connection. This significantly reduces latency for concurrent workloads common in Solid pod interactions (fetching resources, ACLs, containers, profiles, etc.).

Browsers already negotiate HTTP/2 transparently — this addresses the Node.js-only limitation where fetch defaults to HTTP/1.1 with ~6 concurrent TCP connections per origin.

Detailed Changes

New files

  • packages/node/src/http2Fetch.ts — HTTP/2-aware fetch implementation with connection pooling

    • createHttp2Fetch() factory returns a fetch-compatible function with close() method
    • Connection pool: Map<origin, http2.ClientHttp2Session> with configurable idle timeout (default 30s)
    • Automatic error recovery: destroyed/closed sessions are removed and reconnected on next request
    • Full fetch API compatibility: RequestInfo | URL input, RequestInit options, Headers objects, request body streaming
    • Maps fetch API to HTTP/2 pseudo-headers (:method, :path) and streams
    • Exported Http2Fetch type with JSDoc and @since 2.6.0
  • packages/node/src/http2Fetch.spec.ts — 11 tests against a real HTTP/2 server

    • Uses node:http2.createSecureServer with self-signed certificates
    • Covers: GET/POST, custom headers, request body, multiplexing verification, error handling, close(), URL objects, Headers objects, response URL property

Modified files — Session integration

  • packages/node/src/Session.ts

    • Added http2?: boolean to ISessionOptions — creates HTTP/2 fetch when true
    • Unauthenticated requests route through http2Fetch
    • logout() calls http2Fetch.close() to release connections
  • packages/node/src/index.ts — Added createHttp2Fetch and Http2Fetch exports

Modified files — Threading customFetch through the login flow

The buildAuthenticatedFetch function already accepts an options.fetch for the underlying transport. These changes thread the HTTP/2 fetch to that point:

  • packages/core/src/Session.ts — Added customFetch?: typeof fetch to SessionConfig
  • packages/core/src/login/ILoginOptions.ts — Added customFetch field
  • packages/core/src/login/oidc/IOidcOptions.ts — Added customFetch field
  • packages/node/src/ClientAuthentication.ts — Passes config.customFetch to login handler
  • packages/node/src/login/oidc/OidcLoginHandler.ts — Copies customFetch into IOidcOptions
  • packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts — Passes to buildAuthenticatedFetch
  • packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts — Passes through refreshAccess() to buildAuthenticatedFetch
  • packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts — Passes from SessionConfig to buildAuthenticatedFetch

Documentation

  • packages/node/README.md — HTTP/2 Support section with Session usage, standalone usage, and DPoP/Bearer compatibility notes

Data flow

Session({ http2: true })
  → SessionConfig.customFetch = http2Fetch
    → ClientAuthentication.login() passes customFetch in ILoginOptions
      → OidcLoginHandler copies to IOidcOptions
        → OIDC handlers pass to buildAuthenticatedFetch(token, { fetch: customFetch })

DPoP compatibility

Works with both DPoP and Bearer authentication. DPoP proofs are generated per-request by buildAuthenticatedFetch; the HTTP/2 transport is transparent to the auth layer.

Usage

// With Session
const session = new Session({ http2: true });
await session.login({ ... });
const dataset = await getSolidDataset(url, { fetch: session.fetch });
await session.logout(); // Closes HTTP/2 connections

// Standalone
const h2fetch = createHttp2Fetch();
const dataset = await getSolidDataset(url, { fetch: h2fetch });
h2fetch.close();

Test plan

  • 11 new HTTP/2 fetch tests pass
  • 340 existing tests pass (37 suites)
  • Manual integration test with a real Solid pod
  • Verify DPoP authentication over HTTP/2

🤖 Generated with Claude Code

Add an HTTP/2-aware fetch implementation that multiplexes all requests
to the same origin over a single TCP connection, reducing latency for
concurrent workloads common in Solid pod interactions.

- New createHttp2Fetch() factory with connection pooling and idle timeout
- Session({ http2: true }) option for automatic HTTP/2 transport
- Thread customFetch through the login flow to buildAuthenticatedFetch
- Works with both DPoP and Bearer token authentication
- Export createHttp2Fetch and Http2Fetch type from package index
- Add comprehensive tests against real HTTP/2 server
- Update README with usage documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@maxleonard maxleonard requested a review from a team as a code owner March 2, 2026 10:52
fetch: typeof fetch = async (url, init) => {
if (!this.info.isLoggedIn) {
return fetch(url, init);
return (this.http2Fetch ?? fetch)(url, init);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI about 23 hours ago

In general, the problem is that user-controlled resource is used as the URL argument to fetch with no constraints. To fix this without breaking existing semantics, we should validate and normalize the URL before passing it to fetch, and reject or constrain values that don’t meet safe criteria (e.g., enforce HTTPS and disallow internal IPs / localhost in the test server). Since the library Session.fetch is a general-purpose method, we keep it flexible, but we can add a minimal safeguard to ensure the unauthenticated, “raw” fetch path only accepts absolute URLs with allowed protocols. The core SSRF surface exposed over HTTP is the Express test app; there we can be stricter and apply explicit checks.

Concretely:

  1. In e2e/node/server/express.ts:

    • In both /legacy/fetch and /tokens/fetch, after confirming resource is a string, parse it with new URL(resource) inside a try/catch.
    • Enforce that url.protocol is http: or https:.
    • Optionally (and safely) add a basic blocklist for obviously sensitive hosts (loopback / link-local / private networks). Since we must not assume extra infrastructure, we can at minimum prevent localhost and 127.0.0.0/8 and document that this is a safety check for the test server.
    • If the URL fails validation, respond with HTTP 400 and do not call fetch.
    • Use the parsed and validated url.toString() for the fetch call (or keep the original string once validated, but parsing already proves it is a valid URL).
  2. In packages/node/src/Session.ts:

    • Add a shallow check in the unauthenticated branch of fetch: ensure url is either a URL object or a string that parses to a URL with http: or https: protocol. This is mostly defense-in-depth and satisfies CodeQL that we’re not blindly using attacker-controlled protocols; it should not affect normal usage where callers use standard URLs.
    • If the check fails, throw an error, as that’s better than making a dangerous request.

Implementation elements:

  • Use the built-in URL class (global in Node 18+, or import { URL } from "url"; if needed – but Node’s global is likely already available; we can call new URL(...) directly).
  • We do not add any third-party dependencies; native URL handling is sufficient.

The lines to change:

  • e2e/node/server/express.ts:
    • Around lines 146–162 and 164–188 (within /legacy/fetch and /tokens/fetch), wrap resource in validation logic before fetch(resource).
  • packages/node/src/Session.ts:
    • Around line 458–462, extend the unauthenticated branch to validate url before calling (this.http2Fetch ?? fetch)(url, init).

Suggested changeset 2
packages/node/src/Session.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts
--- a/packages/node/src/Session.ts
+++ b/packages/node/src/Session.ts
@@ -457,7 +457,23 @@
    */
   fetch: typeof fetch = async (url, init) => {
     if (!this.info.isLoggedIn) {
-      return (this.http2Fetch ?? fetch)(url, init);
+      // Basic defense-in-depth: ensure that the URL used for unauthenticated
+      // requests is a valid HTTP(S) URL.
+      let normalizedUrl = url;
+      if (typeof url === "string") {
+        try {
+          const parsed = new URL(url);
+          if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+            throw new Error("Unsupported URL protocol");
+          }
+          normalizedUrl = parsed.toString();
+        } catch (e) {
+          throw new Error(
+            `Invalid URL passed to fetch: ${(e as Error).message}`,
+          );
+        }
+      }
+      return (this.http2Fetch ?? fetch)(normalizedUrl as any, init);
     }
     return this.clientAuthentication.fetch(url, init);
   };
EOF
@@ -457,7 +457,23 @@
*/
fetch: typeof fetch = async (url, init) => {
if (!this.info.isLoggedIn) {
return (this.http2Fetch ?? fetch)(url, init);
// Basic defense-in-depth: ensure that the URL used for unauthenticated
// requests is a valid HTTP(S) URL.
let normalizedUrl = url;
if (typeof url === "string") {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Unsupported URL protocol");
}
normalizedUrl = parsed.toString();
} catch (e) {
throw new Error(
`Invalid URL passed to fetch: ${(e as Error).message}`,
);
}
}
return (this.http2Fetch ?? fetch)(normalizedUrl as any, init);
}
return this.clientAuthentication.fetch(url, init);
};
e2e/node/server/express.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/e2e/node/server/express.ts b/e2e/node/server/express.ts
--- a/e2e/node/server/express.ts
+++ b/e2e/node/server/express.ts
@@ -151,10 +151,23 @@
       return;
     }
 
+    let url: URL;
+    try {
+      url = new URL(resource);
+    } catch (_e) {
+      res.status(400).send("invalid resource URL").end();
+      return;
+    }
+
+    if (url.protocol !== "http:" && url.protocol !== "https:") {
+      res.status(400).send("unsupported URL protocol").end();
+      return;
+    }
+
     const session = await getSessionFromStorage(req.session!.sessionId);
 
     const { fetch } = session ?? new Session();
-    const response = await fetch(resource);
+    const response = await fetch(url.toString());
     res
       .status(response.status)
       .send(await response.text())
@@ -169,6 +179,19 @@
       return;
     }
 
+    let url: URL;
+    try {
+      url = new URL(resource);
+    } catch (_e) {
+      res.status(400).send("invalid resource URL").end();
+      return;
+    }
+
+    if (url.protocol !== "http:" && url.protocol !== "https:") {
+      res.status(400).send("unsupported URL protocol").end();
+      return;
+    }
+
     let session;
     const sessionTokenSet = sessionTokenSets.get(req.session!.sessionId);
     if (sessionTokenSet) {
@@ -180,7 +203,7 @@
 
     const { fetch } = session ?? new Session();
 
-    const response = await fetch(resource);
+    const response = await fetch(url.toString());
     res
       .status(response.status)
       .send(await response.text())
EOF
@@ -151,10 +151,23 @@
return;
}

let url: URL;
try {
url = new URL(resource);
} catch (_e) {
res.status(400).send("invalid resource URL").end();
return;
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
res.status(400).send("unsupported URL protocol").end();
return;
}

const session = await getSessionFromStorage(req.session!.sessionId);

const { fetch } = session ?? new Session();
const response = await fetch(resource);
const response = await fetch(url.toString());
res
.status(response.status)
.send(await response.text())
@@ -169,6 +179,19 @@
return;
}

let url: URL;
try {
url = new URL(resource);
} catch (_e) {
res.status(400).send("invalid resource URL").end();
return;
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
res.status(400).send("unsupported URL protocol").end();
return;
}

let session;
const sessionTokenSet = sessionTokenSets.get(req.session!.sessionId);
if (sessionTokenSet) {
@@ -180,7 +203,7 @@

const { fetch } = session ?? new Session();

const response = await fetch(resource);
const response = await fetch(url.toString());
res
.status(response.status)
.send(await response.text())
Copilot is powered by AI and may make mistakes. Always verify output.
Restrict http2Fetch to only allow https: and http: protocols, rejecting
schemes like file: or data: that could enable server-side request forgery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace ~260-line hand-rolled HTTP/2 implementation (connection pooling,
idle timers, pseudo-header mapping, body streaming, response construction)
with undici's built-in HTTP/2 support via Agent({ allowH2: true }).

Same public API (Http2Fetch type, createHttp2Fetch function, close method)
with the same options (idleTimeout, tlsOptions), now in ~40 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant