Skip to content

feat(server): default resolve to app fetch .crossws#200

Merged
pi0 merged 9 commits into
mainfrom
feat/resolve
Jul 3, 2026
Merged

feat(server): default resolve to app fetch .crossws#200
pi0 merged 9 commits into
mainfrom
feat/resolve

Conversation

@pi0x

@pi0x pi0x commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes resolve optional for the crossws/server plugin, and makes hook resolution run exactly once per connection.

Every srvx/h3 user currently hand-writes the same resolve boilerplate to wire crossws back into their own app:

// before
serve(app, {
  plugins: [ws({ resolve: async (req) => (await app.fetch(req)).crossws })],
});

After this change the common case is just:

// after
serve(app, { plugins: [ws()] }); // no resolve

When resolve is omitted, hooks are resolved by calling the srvx server's own fetch handler and reading the .crossws property off the returned Response — the srvx-level convention for attaching WebSocket hooks. The plugin already has server.options.fetch, so it can do this itself.

1. Default resolve (src/server/)

  • New src/server/_resolve.ts — shared defaultResolve(server, wsOpts) helper:
    • Returns an explicit resolve unchanged when provided.
    • Returns undefined when inline hooks (ws({ message })) are present, so those keep running via the adapter's global-hook path with zero per-event overhead (inline and app-resolved modes are mutually exclusive — decision point 1 in the task).
    • Otherwise returns a resolver that reads .crossws off the app's fetch Response.
    • Throws a clear error when the server has no fetch handler: [crossws] server has no fetch handler to resolve WebSocket hooks from.
  • bun.ts, deno.ts, cloudflare.ts, node.ts, bunny.ts — swap resolve: wsOpts.resolveresolve: defaultResolve(server, wsOpts).
  • default.ts (SSE) — refactored so the adapter is built inside the (server) => {} closure (needed to reach server.options.fetch); SSE console.warn preserved.
  • _types.ts — updated WSOptions.resolve JSDoc to document the new default.
  • Drive-by fix: node.ts was spreading wsOpts.options?.deno instead of wsOpts.options?.node.

2. Resolve exactly once per connection (src/hooks.ts)

AdapterHookable.callHook previously invoked options.resolve(request) on every hook event. Since every adapter routes message/open/close/drain/error through callHook, the default resolver's fetch(req) was firing on every message.

Now the resolved hooks are memoized in a WeakMap keyed by the connection's context object — which upgrade() creates (request.context || {}) and every adapter re-exposes verbatim as peer.context. That single shared identity spans both the upgrade event and every later peer event, so one resolve call serves the whole connection (the earlier approach keyed by request resolved twice, because adapters give the peer a different request object than the upgrade request). Keyed weakly, so entries are freed when the connection is GC'd.

This also addresses the per-event cost noted as out-of-scope (decision point 2) in the task.

Tests (test/server-resolve.test.ts)

End-to-end via the node server + ws client, plus unit tests on the shared AdapterHookable.callHook path (the single choke point every provider funnels through, so the guarantee holds cross-provider):

  • No resolve → hooks resolved from the fetch handler's .crossws (open/message/close fire).
  • Explicit resolve still takes precedence.
  • Inline global hooks still work without a resolve or fetch .crossws.
  • Missing server.options.fetch throws the guard error.
  • resolve runs once per connection, not per message (20 messages + open + close ⇒ one resolve; a second connection ⇒ two).
  • upgrade + peer events share a single resolve via context (peer's request deliberately differs from the upgrade request).
  • End-to-end: exactly one fetch serves a whole connection across 21 messages.

Each per-connection test was confirmed to fail on the pre-fix code path and pass after — locking in the guarantee. Full suite: 230 passed / 6 skipped. lint + typecheck clean.

Docs

Added a "Server plugin" section to the resolver guide (docs/1.guide/7.resolver.md) documenting ws() with no resolve, resolve as optional/custom-only, and that resolution is cached per connection.

Notes / follow-ups (out of scope)

  • The per-connection guarantee holds for in-memory adapters. Cloudflare Durable Objects with hibernation (a separate adapter, not the srvx crossws/server default path) reconstruct context on wake, so a woken peer resolves again — the in-memory WeakMap can't survive hibernation regardless.
  • Coordinate the h3 side (feat/ez-ws) docs/examples and crossws peer-range bump once released.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • WebSocket hook resolution now defaults to deriving hooks from the server’s fetch response using crossws when no custom resolver is provided.
    • Adapter behavior now consistently uses this default resolver across supported server runtimes.
  • Bug Fixes

    • Hook resolution is memoized per connection (runs once, not per message), supports explicit overrides, and evicts only failed resolution results for later retry.
    • WebSocket upgrade failures now fail the handshake cleanly with an HTTP 500 (avoids hanging/unhandled errors).
  • Documentation

    • Updated Resolver API guide with a new “Server plugin” section and examples.
  • Tests

    • Added/expanded integration and unit coverage for defaults, precedence, caching, retry semantics, and handshake error handling.

Make `resolve` optional for the `crossws/server` plugin. When omitted, hooks
are resolved by calling the srvx server's own `fetch` handler and reading the
`.crossws` property off the returned `Response`, removing the boilerplate every
srvx/h3 user had to hand-write:

    serve(app, { plugins: [ws()] }); // no resolve

A shared `defaultResolve(server, wsOpts)` helper is used by all six runtime
plugins. Inline hooks (`ws({ message })`) opt out of the fetch-based default and
keep running via the adapter's global-hook path with zero per-event overhead.
Throws a clear error when the server has no `fetch` handler.

Also fixes node.ts spreading `options.deno` instead of `options.node`.

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

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a server-side defaultResolve path for WebSocket hook resolution, wires it through adapters, memoizes resolution per connection, hardens upgrade failure handling, and expands docs and tests for fetch-based resolution and overrides.

Changes

Default resolve and adapter wiring

Layer / File(s) Summary
defaultResolve helper and type docs
src/server/_resolve.ts, src/server/_types.ts
Adds defaultResolve(server, wsOpts) plus inline-hook detection and fetch-based Response.crossws resolution, and documents the resolve option behavior.
Adapter wiring across server runtimes
src/server/bun.ts, src/server/bunny.ts, src/server/cloudflare.ts, src/server/default.ts, src/server/deno.ts, src/server/node.ts
All adapters now derive resolve through defaultResolve(server, wsOpts); default.ts defers adapter creation until the server callback, and node.ts switches to node-specific options.
Per-connection resolve caching
src/hooks.ts
AdapterHookable.callHook memoizes resolved hooks per connection identity and seeds that cache during upgrade handling.

Handshake and resolver coverage

Layer / File(s) Summary
Handshake error handling and resolver tests
src/adapters/node.ts, docs/1.guide/7.resolver.md, test/server-resolve.test.ts
Node upgrade failures now return a clean HTTP 500 response, and the resolver guide plus tests cover fetch-derived hooks, explicit overrides, inline hooks, cached resolution, and missing-fetch behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • h3js/crossws#179: Overlaps with Bunny adapter resolve wiring and server-plugin resolution behavior.
  • h3js/crossws#185: Touches WebSocket hook handling in src/hooks.ts and upgrade-path behavior in the Node adapter.

Suggested labels: enhancement

Suggested reviewers: pi0

Poem

A rabbit watched the hooks align,
One resolve per peer, by cache design.
Fetch brought crossws in the light,
And upgrade failures now end right. 🐇

🚥 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 accurately summarizes the main change: defaulting server resolve to the app fetch .crossws path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resolve

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.

@pi0x pi0x marked this pull request as ready for review July 3, 2026 07:11
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

`AdapterHookable.callHook` called `options.resolve(request)` on every hook
event — meaning the default `crossws/server` resolver ran the app's `fetch`
handler on *every message*. Memoize the resolved hooks per connection in a
`WeakMap` keyed by the stable `peer.request` identity that every runtime
adapter shares, so `resolve` runs at most once per connection.

Adds cross-provider tests (asserted on the shared `callHook` path plus an
end-to-end node run) proving `resolve`/`fetch` is not invoked per message.

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

pi0x commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: resolve now runs once per connection (not per message)

Addressed the per-event cost that was originally noted as out-of-scope (decision point 2 in the task).

Problem: AdapterHookable.callHook invoked options.resolve(request) on every hook event. Since every adapter routes message/open/close/drain/error through callHook, the default resolver's fetch(req) was firing on every message.

Fix (src/hooks.ts): memoize the resolved hooks in a WeakMap keyed by the stable peer.request identity that all runtime adapters share, so resolve runs at most once per connection. Keyed weakly → entries are freed when the peer/request is GC'd.

Cross-provider tests (test/server-resolve.test.ts):

  • Unit test on the shared AdapterHookable.callHook path (the single choke point every provider funnels through): 20 messages + open + close ⇒ resolve called once; a second distinct connection ⇒ called twice.
  • End-to-end node run: snapshot fetch call count after the first message, send 20 more, assert the count is unchanged.

Both tests were confirmed to fail on the pre-fix (per-event) code path and pass after the fix. Full suite: 229 passed / 6 skipped, lint + typecheck clean.

@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/hooks.ts`:
- Around line 31-45: The resolve() result cached in `#resolveCache` can remain as
a rejected promise and poison later hooks for the same request/peer. Update the
hook resolution path in hooks.ts around resolve and `#resolveCache` so rejected
resolveHooksPromise entries are removed from the WeakMap on failure, allowing
subsequent events for that request/Peer to call resolve again and recover from
transient errors.
🪄 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: c96b393f-1bf3-4772-b3e2-495636b75503

📥 Commits

Reviewing files that changed from the base of the PR and between e30c79b and da9b49e.

📒 Files selected for processing (3)
  • docs/1.guide/7.resolver.md
  • src/hooks.ts
  • test/server-resolve.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/1.guide/7.resolver.md

Comment thread src/hooks.ts
Key the resolve cache by the connection's `context` object instead of the
request. `upgrade()` creates `context` (`request.context || {}`) and every
adapter re-exposes that exact object as `peer.context`, so a single identity
spans both the `upgrade` event and every later peer event — collapsing the
previous two resolves per connection (upgrade + peer, which use different
`request` objects) into one.

`upgrade()` seeds the cache by passing `context` to `callHook`; peer events
derive the same key from `peer.context`.

Tests assert exactly one resolve/fetch spans a whole connection, including a
peer whose `request` differs from the upgrade request.

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.

♻️ Duplicate comments (1)
src/hooks.ts (1)

42-52: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Evict rejected resolve() results from #resolveCache.

If resolve(request) rejects, the rejected promise is cached in #resolveCache and reused for every subsequent hook call on the same connection — a single transient resolver failure permanently breaks that connection instead of allowing retry on the next event.

🛠️ Proposed fix
     if (this.#resolveCache.has(cacheKey)) {
       resolveHooksPromise = this.#resolveCache.get(cacheKey);
     } else {
       resolveHooksPromise = resolve(request);
+      if (resolveHooksPromise instanceof Promise) {
+        resolveHooksPromise.catch(() => this.#resolveCache.delete(cacheKey));
+      }
       this.#resolveCache.set(cacheKey, resolveHooksPromise);
     }
🤖 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/hooks.ts` around lines 42 - 52, Evict failed hook resolution results from
the shared cache in hooks.ts: when `resolve(request)` rejects in the
`#resolveCache` lookup path, remove that `cacheKey` entry so a transient failure
doesn’t poison future events for the same connection. Update the logic around
`resolveHooksPromise`, `#resolveCache.has/get/set`, and `resolve(request)` so
only successful resolutions stay cached and rejected promises are not reused.
🤖 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.

Duplicate comments:
In `@src/hooks.ts`:
- Around line 42-52: Evict failed hook resolution results from the shared cache
in hooks.ts: when `resolve(request)` rejects in the `#resolveCache` lookup path,
remove that `cacheKey` entry so a transient failure doesn’t poison future events
for the same connection. Update the logic around `resolveHooksPromise`,
`#resolveCache.has/get/set`, and `resolve(request)` so only successful
resolutions stay cached and rejected promises are not reused.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 576053c4-062b-410b-bba6-a5940273ede7

📥 Commits

Reviewing files that changed from the base of the PR and between da9b49e and a1c4a38.

📒 Files selected for processing (2)
  • src/hooks.ts
  • test/server-resolve.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/server-resolve.test.ts

pi0 and others added 3 commits July 3, 2026 07:58
A rejected `resolve` promise was memoized permanently, poisoning every later
hook event on the connection and never retrying — a single transient failure
(e.g. the default resolver's `fetch` rejecting once) would kill the connection.
Attach a guarded `catch` that removes the failed entry from the cache so the
next event re-invokes `resolve` and recovers. The catch is a separate branch
and does not swallow the rejection surfaced to the current event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default resolver now runs the app `fetch` on every WS upgrade, which
surfaced a few sharp edges when that handler misbehaves:

- Normalize a *synchronous* throw from `resolve` into a rejected promise
  in `callHook`, so it flows through the existing eviction/`.catch` path
  instead of escaping as an uncaught exception on fire-and-forget event
  call sites (message/close/…).
- Catch a failed `upgrade()` in the node adapter's `handleUpgrade` (which
  the server plugin invokes fire-and-forget) and fail the handshake with
  a 500, instead of hanging the socket and raising an unhandled rejection.
- Cancel the discarded response body in the default resolver so a
  streaming/proxied body on the upgrade path isn't leaked per connection.
- Add a compile-time exhaustiveness guard for `HOOK_NAMES` so a hook added
  to `Hooks` but not listed can't silently break inline-hook detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Explain that the default resolver calls the app `fetch` with the upgrade
request once per connection: attach hooks via `.crossws`, keep `fetch`
side-effect-safe, and how throwing vs. returning a non-crossws Response
affects the handshake.

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/server-resolve.test.ts (1)

314-350: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: consolidate near-duplicate handshake-failure tests.

Both tests differ only in how fetch fails (sync throw vs. rejected promise). Could parameterize with test.each to reduce duplication.

♻️ Example consolidation
-test("a synchronously throwing app fetch fails the handshake cleanly", async () => {
-  ...
-});
-
-test("a rejecting app fetch fails the handshake cleanly", async () => {
-  ...
-});
+test.each([
+  ["synchronously throwing", () => { throw new Error("boom"); }],
+  ["rejecting", () => Promise.reject(new Error("boom"))],
+])("a %s app fetch fails the handshake cleanly", async (_label, fetchImpl) => {
+  const port = await getRandomPort("localhost");
+  const server = serve({
+    port,
+    hostname: "127.0.0.1",
+    fetch: fetchImpl,
+    websocket: {},
+  });
+  currentServer = server;
+  await server.ready();
+
+  const client = new WebSocket(`ws://127.0.0.1:${port}/`);
+  const [error] = await once(client, "error");
+  expect(error).toBeInstanceOf(Error);
+});
🤖 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/server-resolve.test.ts` around lines 314 - 350, Consolidate the two
near-identical handshake failure cases in server-resolve.test.ts into a single
parameterized test using test.each, keeping both failure modes covered while
reducing duplication. Update the existing WebSocket handshake assertions around
serve(), currentServer, and the default websocket resolver so the only variable
is the fetch failure behavior (sync throw vs Promise rejection).
🤖 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/adapters/node.ts`:
- Around line 119-129: The handshake fallback in `handleUpgrade` is too brittle
because the `catch` block discards the rejection reason and the `sendResponse`
failure can still surface as another unhandled rejection. Update the
`hooks.upgrade` try/catch to capture the error for diagnostics, then make the
500-response path best-effort by guarding or swallowing failures from
`sendResponse(socket, new Response(...))` so a closed socket does not rethrow.
Reference the `handleUpgrade` flow and the `hooks.upgrade` / `sendResponse`
calls when applying the fix.

---

Nitpick comments:
In `@test/server-resolve.test.ts`:
- Around line 314-350: Consolidate the two near-identical handshake failure
cases in server-resolve.test.ts into a single parameterized test using
test.each, keeping both failure modes covered while reducing duplication. Update
the existing WebSocket handshake assertions around serve(), currentServer, and
the default websocket resolver so the only variable is the fetch failure
behavior (sync throw vs Promise rejection).
🪄 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: 5ff20486-e642-4547-a8ec-c2e718480dff

📥 Commits

Reviewing files that changed from the base of the PR and between 8ac7dd8 and 0bbebe4.

📒 Files selected for processing (4)
  • src/adapters/node.ts
  • src/hooks.ts
  • src/server/_resolve.ts
  • test/server-resolve.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/server/_resolve.ts
  • src/hooks.ts

Comment thread src/adapters/node.ts
pi0 and others added 3 commits July 3, 2026 08:34
The default resolver now accepts either a `Response` carrying `.crossws`
or a plain `{ crossws, headers }` object from the app `fetch` handler for
upgrade requests. Optional `headers` are applied to the WebSocket
handshake response (composed with any `upgrade` hook from `.crossws`). A
real Response's own HTTP headers are still not treated as handshake
headers.

The `fetch` return type in `ServerWithWSOptions` is widened accordingly
(new exported `WSUpgradeResult` type), with the srvx `serve` wrappers
casting back to `ServerOptions`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the app `fetch` returns a `Response` without `.crossws` on the
upgrade path, a non-`2xx` response (error or redirect, e.g.
`new Response("Unauthorized", { status: 401 })`) is now sent back to the
client and the handshake is rejected, rather than silently opening a
handler-less socket. A plain `2xx` response without hooks still upgrades
with no hooks (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`res.ok` is only true for 2xx, so a `101` (Switching Protocols) response
— e.g. the one Cloudflare's `WebSocketPair` produces — would be rendered
as an error by the non-ok render path. Exclude `101` so it proceeds with
the upgrade (hook-less when no `.crossws`) instead of failing the
handshake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pi0 pi0 merged commit b86b01d into main Jul 3, 2026
6 checks passed
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