Skip to content

fix: edge adapter correctness + adapter test coverage (F29, F46, F9)#242

Open
pi0x wants to merge 1 commit into
mainfrom
fix/edge-adapters
Open

fix: edge adapter correctness + adapter test coverage (F29, F46, F9)#242
pi0x wants to merge 1 commit into
mainfrom
fix/edge-adapters

Conversation

@pi0x

@pi0x pi0x commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Edge-adapter batch of the v1 stabilization plan.

Fixes

Cloudflare — F29 (locked: keep the global fetch listener, fix the bugs)

  • No double-registration: serve() retains its listener; repeated calls (or manual: true then serve()) no longer stack a second listener, which previously caused a double respondWith() error.
  • close() removes the listener it registered.
  • Threads env through the service-worker listener instead of a hardcoded {}. Cloudflare exposes bindings as globals in service-worker syntax, so env is only meaningfully available via module-worker syntax — documented in the class JSDoc.
  • request.ip is now configurable: true (matching bun/deno) so trustProxy can override it; still populated from cf-connecting-ip.

Bunny

  • Guard the waitUntil closure with typeof Bunny !== "undefined" so it no longer throws a ReferenceError when the adapter runs outside the Bunny runtime (e.g. manual: true).

Service worker

  • close() unregisters only the registration serve() created, instead of every service worker on the origin.

Tests

F46 — adapter coverage

  • test/generic.test.ts — in-process tests (middleware, plugins, error, waitUntil/close).
  • test/bunny.test.ts — mocked Bunny global (registration, single-registration, waitUntil delegation, ip, and the no-runtime ReferenceError regression).
  • test/cloudflare.test.ts — best-effort in-process test with mocked service-worker globals + direct module-worker fetch(request, env, context) calls, covering all three F29 behaviors and the configurable ip.
    • Note: the plan's preferred @cloudflare/vitest-pool-workers smoke job needs its own worker runtime + wrangler + a separate vitest project, which conflicts with the existing single-config Node suite, so the in-process fallback was used (as the plan allows).

F9 — error paths (one unhandled-throw test per runtime)

  • test/_error-fixture.ts — a server with no error option (so errorPlugin no-ops and throws propagate to the runtime).
  • Deno & Bun (test/_error-tests.ts, wired into deno.test.ts / bun.test.ts — zero extra CI wiring): both answer 500 and keep serving.
  • Node (test/node-error-paths.test.ts, in-process): documents Node's current behavior — an unhandled throw escapes as an uncaughtException and leaves the connection hanging (an unguarded server would crash). Making Node answer 500 like Deno/Bun belongs to the Node adapter scope, so it is documented, not fixed here.

Verification

  • pnpm lint clean, pnpm typecheck clean (after pnpm build), full vitest run: 1002 passed / 35 skipped.

🤖 Generated with Claude Code

Cloudflare (F29, locked: keep the global fetch listener, fix the bugs):
- Guard against double-registration: repeated `serve()` calls no longer
  stack duplicate `fetch` listeners (which caused a double `respondWith`).
- `close()` now removes exactly the listener `serve()` added.
- Thread the event's env through the service-worker listener instead of a
  hardcoded `{}`, and document that env bindings are only available in
  module-worker syntax.
- Make `request.ip` `configurable` (like bun/deno) so `trustProxy` can
  override it; still populated from `cf-connecting-ip`.

Bunny:
- Guard the `waitUntil` closure with `typeof Bunny` so it no longer throws
  a `ReferenceError` outside the Bunny runtime (e.g. `manual: true`).

Service worker:
- `close()` now unregisters only the registration `serve()` created,
  instead of every service worker on the origin.

Tests (F46 + F9):
- Add in-process tests for the `generic`, `bunny`, and `cloudflare`
  adapters (cloudflare via mocked service-worker/module-worker globals —
  best-effort in place of a @cloudflare/vitest-pool-workers job, which
  needs a separate worker runtime/config).
- Add one unhandled-throw test per runtime (F9): Deno and Bun answer 500
  and keep serving; Node's current behavior (an uncaughtException that
  would crash the process) is captured and documented — the fix belongs
  to the Node adapter scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pi0x pi0x requested a review from pi0 as a code owner July 14, 2026 12:50
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@pi0x, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 927810ee-c8ae-4277-a940-200388da68ca

📥 Commits

Reviewing files that changed from the base of the PR and between d8523dc and 6817bc3.

📒 Files selected for processing (11)
  • src/adapters/bunny.ts
  • src/adapters/cloudflare.ts
  • src/adapters/service-worker.ts
  • test/_error-fixture.ts
  • test/_error-tests.ts
  • test/bun.test.ts
  • test/bunny.test.ts
  • test/cloudflare.test.ts
  • test/deno.test.ts
  • test/generic.test.ts
  • test/node-error-paths.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/edge-adapters

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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/srvx@242

commit: 6817bc3

@pi0x pi0x left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review: PR #242 — edge adapter correctness + adapter test coverage

Verdict: Approve with minor findings. No blocking bugs. Scope is clean (only cloudflare.ts, bunny.ts, service-worker.ts + test files; no deno.ts/bun.ts/_plugins.ts source, no prose docs). Full suite green: pnpm vitest run = 1002 passed / 35 skipped; lint + typecheck (post-build) clean; new files stable over 5 isolated runs; bun/deno error-path additions verified locally (both return 500 and stay alive).

Spec checklist

Item Status
F29(a) no double-registration on repeated serve() PASS — per-instance #fetchListener guard, early-return
F29(b) close() removes exactly that listener PASS — removeEventListener + clears the field; serve() after close() re-registers
F29(c) real env threaded instead of {} PARTIAL — see F-1 (module-worker path already correct on main; the SW-listener change is a no-op)
F29(d) JSDoc: bindings module-syntax-only PASS — accurate and honest that SW-mode env is {}
CF ip configurable:true from cf-connecting-ip PASS
Bunny waitUntil typeof Bunny guard PASS — no ReferenceError under manual:true; short-circuits cleanly when Bunny exists but unstable doesn't
SW close() unregisters only its own registration PASS for the over-unregister bug; see F-2 for the async race
F46 generic/bunny/cloudflare tests PASS — genuinely exercise the guard + close paths (not just mirrors); caveat in F-1
F9 bun/deno exec 500 + alive PASS
F9 node uncaughtException documented not fixed PASS — honest (independently verified below)

Findings

F-1 (low / accuracy) — cloudflare.ts:88: the service-worker env thread is dead code. event.respondWith(this.fetch(event.request, (event as any).env || {}, event)) — a service-worker FetchEvent never carries .env (bindings are globals), so this is always {}. The meaningful env threading is the module-worker path (this.fetch = (request, env, context) => … { env, context }), which already existed on main — this PR does not change it. So F29(c) is effectively already satisfied for the case that matters, and the listener edit neither can nor does improve it. Not a bug (the JSDoc is honest), but the PR description oversells "threads env through the service-worker listener." Consider dropping the (event as any).env read for a plain {} to avoid implying a binding source that cannot exist.

F-2 (low / browser-only race) — service-worker.ts:125-137: close() does not await #listeningPromise. #registration is only assigned inside the register().then() at :74-75. If close() runs before that promise resolves (serve() immediately followed by close()), #registration is still undefined, await this.#registration?.unregister() no-ops, and the registration completes afterward — leaving a worker registered that close() was meant to remove. The spec explicitly asked to check this race; the fix is one line: await this.#listeningPromise before the unregister block. Narrow (browser-window mode, tight timing) so non-blocking, but it is a real gap against the stated spec.

F-3 (informational) — cloudflare.ts:35,81: the double-registration guard is per-instance. Two separate serve() calls (two CloudflareServer instances) each add a global fetch listener → double respondWith() on the same event → Cloudflare throws. The guard only covers same-instance repeat calls (which is what the spec asked for). Pre-existing footgun for SW syntax; noting for awareness, not a blocker.

F-4 (informational) — node-error-paths.test.ts: weak OR assertion. expect(uncaught || (status !== undefined && status >= 500)) passes whether Node hangs (uncaughtException) or returns 500, so it would not catch a future change in either direction. I verified independently that today the escape genuinely happens — a sync throw yields uncaughtException:"unhandled sync error" and the client fetch times out (~1.5s, no response); the async path yields unhandledRejection + the same hang. So the test is honest now, but the assertion doesn't lock the documented behavior. Containment is adequate: prependListener("uncaughtException") + afterEach cleanup; no flake across isolated and full-suite runs, vitest worker survives.

Escalation recommendation (Node uncaughtException)

Yes — escalate to a code fix in the Node scope. This is a genuine cross-runtime reliability defect, not just a doc note: an unhandled handler throw on Node hangs the socket and, on an unguarded process, crashes via uncaughtException — while Deno and Bun both answer 500 and keep serving. That is a DoS-shaped hang and a portability trap (code written on Node "works" until moved). node.ts should catch handler throws/rejections and emit 500 like the other runtimes. This PR correctly documents-and-defers (node.ts is out of its scope); recommend filing it as a Node-scope T1/T2 item rather than leaving it only as a test comment.

Nice work overall — the guard/close/env-doc trio is coherent, the bunny guard is correct, and the F9 exec tests are a clean, zero-CI-wiring addition.

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