Skip to content

feat(proxy): support async target resolver in createWebSocketProxy - #196

Merged
pi0 merged 2 commits into
mainfrom
feat/proxy-async-target
Jun 30, 2026
Merged

feat(proxy): support async target resolver in createWebSocketProxy#196
pi0 merged 2 commits into
mainfrom
feat/proxy-async-target

Conversation

@pi0x

@pi0x pi0x commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Allow createWebSocketProxy's target resolver to be async (return a promise). Until now it was synchronous only, so consumers whose upstream address isn't known at connect time — a worker still booting, a backend being hot-reloaded, a registry lookup — had to wrap the proxy hooks to await readiness before dialing.

Motivated by unjs/env-runner#33, which hand-rolled exactly this: a custom upgrade hook awaiting worker readiness around the proxy. With an async target that collapses to a one-liner:

createWebSocketProxy({
  target: async () => {
    const addr = await worker.waitForAddress();
    return `ws://${addr.host}:${addr.port}/`;
  },
});

What changed

  • Type: target now accepts (peer) => string | URL | Promise<string | URL>.
  • open hook restructured: registers the per-peer state (and starts connectTimeout) up front, then dials the upstream synchronously or once the async target settles.
    • Client frames sent while the target resolves are buffered (existing maxBufferSize path) and flushed on upstream open.
    • connectTimeout now also bounds a resolver that never settles → peer closed 1011 instead of hanging.
    • A rejecting/throwing resolver → peer closed 1011.
  • UpstreamState.ws is now optional with guards in close/error/message/_cleanupState for the "peer closed mid-resolution" window.
  • Dial logic extracted into _dialUpstream.

No behavior change for synchronous string/URL/function targets.

Tests

3 new cases: async resolver proxies successfully, early frames buffer-and-flush across the async gap, and a rejecting resolver closes with 1011. Full suite green (156 passed).

Docs

Documented the async resolver under "Dynamic target" and updated the API signature + connectTimeout note in docs/1.guide/7.proxy.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • WebSocket proxy targets can now be resolved asynchronously (including Promise-based and thenable resolvers) before connecting.
    • Client frames sent during target resolution are buffered and automatically forwarded once the upstream connection is established.
  • Bug Fixes

    • Connection timeouts now account for time spent resolving the target as well as the upstream handshake.
    • If async target resolution fails or exceeds the timeout, the connection is closed with code 1011 (and when timeouts are disabled, unresolved peers are limited by buffering and may close with 1009).
  • Tests / Documentation

    • Added integration tests for async/thenable target resolution and failure cases, and updated the proxy guide accordingly.

The `target` resolver was synchronous only, so consumers whose upstream
address isn't known at connect time (a worker still booting, a backend
being hot-reloaded) had to wrap the proxy hooks to await readiness before
dialing.

Allow `target` to return a promise. The `open` hook now registers the
per-peer state up front (so client frames sent while the target resolves
are buffered, bounded by `maxBufferSize`) and dials the upstream once the
URL settles. `connectTimeout` covers the resolution too, so a resolver
that never settles closes the peer with 1011 instead of hanging; a
rejecting resolver closes with 1011 as well.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

createWebSocketProxy now accepts async or thenable target resolvers. Client frames buffer during resolution, connectTimeout includes resolver time, failures close with 1011, and docs and tests were updated.

Changes

Async Target Resolution for WebSocket Proxy

Layer / File(s) Summary
Type update and _resolveTarget async support
src/proxy.ts
WebSocketProxyOptions.target now accepts Promise<string | URL> return values, and _resolveTarget returns URL | Promise<URL> while coercing async results into URL.
open() async dial lifecycle and buffering
src/proxy.ts
open() registers UpstreamState with ws unset before resolution, starts connectTimeout immediately, defers dialing for async target resolution, and closes the peer with 1011 on sync failure, async rejection, or stale resolution.
UpstreamState optional ws and lifecycle hooks
src/proxy.ts
UpstreamState.ws becomes optional, _dialUpstream assigns the upstream socket, message/close/error hooks use optional chaining, and _cleanupState only closes an existing upstream.
Integration tests for async resolver buffering and failure
test/proxy.test.ts
Integration tests cover delayed promise resolution, thenable resolution, buffered frame flush after upstream connection, and 1011 closure when async resolution throws.
Docs for async target and connectTimeout
docs/1.guide/7.proxy.md
The proxy guide documents async resolver buffering, resolver-time connectTimeout behavior, and the updated target option signature.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • h3js/crossws#184: Introduced createWebSocketProxy; this PR extends its proxy resolution and buffering path to support async target resolution.

Suggested reviewers

  • pi0

Poem

🐇 A promise hopped in, the frames waited near,
The proxy held still till the target appeared.
Then echoes went through, or 1011 spoke clear,
This rabbit approves of the async frontier.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 change: adding async target resolver support in createWebSocketProxy.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/proxy-async-target

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: 2

🧹 Nitpick comments (4)
docs/1.guide/7.proxy.md (1)

79-90: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider showing the peer parameter in the async example for consistency.

The synchronous dynamic-target example at line 69 accepts peer, but the async example at line 84 omits it. Since the resolver type is (peer: Peer) => ..., including the parameter (even if unused) aligns the snippet with the API signature readers just saw and avoids the impression that async resolvers have a different signature.

   target: async () => {
+  target: async (peer) => {

This is optional — the code is valid as-is.

🤖 Prompt for 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.

In `@docs/1.guide/7.proxy.md` around lines 79 - 90, The async `target` example in
`createWebSocketProxy` should show the same resolver signature as the
synchronous example by including the `peer` parameter, even if it is unused.
Update the snippet in the proxy guide so the async resolver is clearly `(peer)
=> ...` to match the `Peer`-based API shape readers already saw and keep both
examples consistent.
test/proxy.test.ts (2)

199-215: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the buffering test deterministic.

With the fixed 50ms delay, a slow CI run could resolve the target before Line 214 sends "early", so the test may pass without proving buffering. Use a manually resolved promise to guarantee the frame is sent during the async gap.

🧪 Proposed deterministic test flow
+    let resolveTarget!: (url: string) => void;
+    const targetReady = new Promise<string>((resolve) => {
+      resolveTarget = resolve;
+    });
     const asyncProxy = nodeAdapter({
       hooks: createWebSocketProxy({
-        target: async () => {
-          await new Promise((r) => setTimeout(r, 50));
-          return upstreamURL;
-        },
+        target: () => targetReady,
       }),
     });
@@
       const ws = await wsConnect(`ws://localhost:${port}/`, { skip: 1 });
       await ws.send("early");
+      resolveTarget(upstreamURL);
       expect(await ws.next()).toBe("echo:early");
🤖 Prompt for 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.

In `@test/proxy.test.ts` around lines 199 - 215, The buffering test is
timing-dependent because the fixed delay in the async target can resolve before
the ws.send("early") call runs. Update the proxy test around
asyncProxy.handleUpgrade and wsConnect to use a manually controlled promise for
the target resolution so the frame is definitely sent while the upstream is
still unresolved, then resolve it explicitly before asserting the echoed
message.

222-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for a never-settling resolver timeout.

This verifies rejection, but not the new connectTimeout behavior for target: () => new Promise(() => {}). Add a small test with a short connectTimeout and assert the peer closes with 1011.

🤖 Prompt for 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.

In `@test/proxy.test.ts` around lines 222 - 245, Add a focused test in
proxy.test.ts alongside the existing createWebSocketProxy/handleUpgrade coverage
that uses a target resolver returning a never-settling Promise and a short
connectTimeout, then assert the ws peer closes with code 1011. Reuse the
existing test setup patterns (nodeAdapter, createServer, wsConnect, CloseEvent)
so the new case specifically verifies the connectTimeout path rather than the
rejection path.
src/proxy.ts (1)

376-381: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a thenable check for async targets instanceof Promise misses cross-realm promises/thenables, so _resolveTarget() can fall through to new URL(raw) and reject valid async resolvers.

🤖 Prompt for 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.

In `@src/proxy.ts` around lines 376 - 381, In _resolveTarget, the async-target
detection is too narrow because using instanceof Promise can miss thenables or
cross-realm promises. Update the target resolution logic to use a thenable-style
check on the raw value returned from the target resolver, and keep the existing
URL coercion path for both sync and async results so valid async resolvers don’t
fall through to new URL(raw).
🤖 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/proxy.ts`:
- Around line 25-26: The `connectTimeout` documentation in `proxy.ts` is
inconsistent between the top-level paragraph and the option description. Update
the `connectTimeout` option docs to match the behavior described near the
`Proxy`/`connectTimeout` references by stating it also covers target resolution,
not just the upstream handshake, so both descriptions use the same wording and
meaning.

In `@test/proxy.test.ts`:
- Around line 236-239: The close event listener in the proxy test is attached
too late, so the connection may close before the handler is registered and the
test can hang. Update the test around wsConnect and the
ws.ws.addEventListener("close", ...) setup so the close listener is registered
before awaiting the connection or otherwise ensure the listener is attached
first, keeping the existing Promise<CloseEvent> flow intact.

---

Nitpick comments:
In `@docs/1.guide/7.proxy.md`:
- Around line 79-90: The async `target` example in `createWebSocketProxy` should
show the same resolver signature as the synchronous example by including the
`peer` parameter, even if it is unused. Update the snippet in the proxy guide so
the async resolver is clearly `(peer) => ...` to match the `Peer`-based API
shape readers already saw and keep both examples consistent.

In `@src/proxy.ts`:
- Around line 376-381: In _resolveTarget, the async-target detection is too
narrow because using instanceof Promise can miss thenables or cross-realm
promises. Update the target resolution logic to use a thenable-style check on
the raw value returned from the target resolver, and keep the existing URL
coercion path for both sync and async results so valid async resolvers don’t
fall through to new URL(raw).

In `@test/proxy.test.ts`:
- Around line 199-215: The buffering test is timing-dependent because the fixed
delay in the async target can resolve before the ws.send("early") call runs.
Update the proxy test around asyncProxy.handleUpgrade and wsConnect to use a
manually controlled promise for the target resolution so the frame is definitely
sent while the upstream is still unresolved, then resolve it explicitly before
asserting the echoed message.
- Around line 222-245: Add a focused test in proxy.test.ts alongside the
existing createWebSocketProxy/handleUpgrade coverage that uses a target resolver
returning a never-settling Promise and a short connectTimeout, then assert the
ws peer closes with code 1011. Reuse the existing test setup patterns
(nodeAdapter, createServer, wsConnect, CloseEvent) so the new case specifically
verifies the connectTimeout path rather than the rejection path.
🪄 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: 2933688f-a82d-44a5-bfe7-2da0e15a0e46

📥 Commits

Reviewing files that changed from the base of the PR and between ac58036 and 1288143.

📒 Files selected for processing (3)
  • docs/1.guide/7.proxy.md
  • src/proxy.ts
  • test/proxy.test.ts

Comment thread src/proxy.ts Outdated
Comment thread test/proxy.test.ts
- await non-native thenables (structural check, not `instanceof Promise`)
  so Bluebird/cross-realm/custom thenable resolvers don't get stringified
  into `new URL()` and close every connection with 1011
- guard the upstream `message` listener with the stale-state check used by
  its sibling open/close/error listeners, so an in-flight upstream frame
  can't leak to a peer the proxy already tore down
- wrap the buffer-flush loop in try/catch and clear the buffer in finally,
  matching the message hook, so an upstream racing into CLOSING after `open`
  doesn't let the exception escape or strand the buffer
- document that the never-settles -> 1011 guarantee requires a non-zero
  connectTimeout; with `0` the peer is bounded only by maxBufferSize (1009)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
test/proxy.test.ts (1)

222-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared test server setup/teardown.

This test, along with the delayed-promise test (194-220) and the rejecting-resolver test (252-276), repeats the same createServer/listen/waitForPort/try-finally cleanup boilerplate. A small helper (e.g. withTestServer(handler, fn)) would reduce duplication across the three new cases.

🤖 Prompt for 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.

In `@test/proxy.test.ts` around lines 222 - 250, The new WebSocket proxy tests
repeat the same server lifecycle boilerplate in the non-native thenable,
delayed-promise, and rejecting-resolver cases. Extract the shared
`createServer`/`listen`/`waitForPort`/`try-finally` cleanup into a small helper
such as `withTestServer`, and use it in these tests so the setup/teardown stays
consistent and the test bodies focus on resolver behavior.
🤖 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.

Nitpick comments:
In `@test/proxy.test.ts`:
- Around line 222-250: The new WebSocket proxy tests repeat the same server
lifecycle boilerplate in the non-native thenable, delayed-promise, and
rejecting-resolver cases. Extract the shared
`createServer`/`listen`/`waitForPort`/`try-finally` cleanup into a small helper
such as `withTestServer`, and use it in these tests so the setup/teardown stays
consistent and the test bodies focus on resolver behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ca34495a-d6f1-4324-8caa-a758f3b4e576

📥 Commits

Reviewing files that changed from the base of the PR and between 1288143 and 55eb29d.

📒 Files selected for processing (3)
  • docs/1.guide/7.proxy.md
  • src/proxy.ts
  • test/proxy.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/proxy.ts

@pi0
pi0 merged commit 34e4fad into main Jun 30, 2026
6 checks passed
@pi0
pi0 deleted the feat/proxy-async-target branch June 30, 2026 21:37
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