Skip to content

feat: add newInMemoryRpcSessionPair for testing - #276

Open
ndisidore wants to merge 1 commit into
cloudflare:mainfrom
ndisidore:feat/in-memory-session-pair
Open

ndisidore wants to merge 1 commit into
cloudflare:mainfrom
ndisidore:feat/in-memory-session-pair

Conversation

@ndisidore

@ndisidore ndisidore commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

In cloudflare-os and even here locally we find ourselves hand-rolling mock/stubs of cap'n web for testing (unless we go a heavier route through workerd). This is especially annoying as cap'n web rejects calls from anything without its symbol/brand, so these almost always have to take the form of fakes.
This introduces an in-memory transport, useful for primarily testing situations. Everything else is real: serialization, deserialization, import/export tables, pipelining, ...

connectrpc takes a similar approach: https://connectrpc.com/docs/node/testing/#testing-against-an-in-memory-server

@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a6de425

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
capnweb Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@276
npm i https://pkg.pr.new/cloudflare/capnweb/capnweb-validate@276

commit: a6de425

@ndisidore

Copy link
Copy Markdown
Member Author

@ask-bonk review this

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

The change is small and well scoped, and I think it's worth adding. Every layer except the transport is real, so tests can catch missing dup() calls and disposal bugs that hand-rolled mocks never would. Swapping the ad-hoc SessionPair in limits.test.ts for this helper also cleans things up.

I traced the abort and disconnect paths and they look right:

  • Self-abort: when a session aborts on its own (for example, a limits violation), RpcSessionImpl.abort() sends the abort message before it calls transport.abort(). The message is either handed to the peer's pending receiver or queued, and receive() drains the queue before rejecting. So the peer sees the original error, not a generic break.
  • disconnect(): send() quietly drops messages once the peer is gone, so the abort message each session tries to send when it breaks goes nowhere, which is what you want.
  • No unhandled rejections: the rejected #receiver promise is always inside the readLoop Promise.race, so rejecting it doesn't leak.

The inline comments are small, non-blocking suggestions.

Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/index.ts
Comment thread __tests__/index.test.ts
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the feat/in-memory-session-pair branch from d8a7fef to d6d6cca Compare September 24, 2026 22:46
@ndisidore

Copy link
Copy Markdown
Member Author

@ask-bonk review this

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

Re-reviewed at d6d6cca. The earlier review was on d8a7fef, and this push covers all four of its comments:

  • Function mains: PartialMain<T> keeps call signatures, so a function main has to match the whole function type. The @ts-expect-error cases for Formatter and PublicApi test this.
  • Client main: clientMain is now supported, typed by C, and server.getRemoteMain() returns RpcStub<C>.
  • Return type: the function now returns a named InMemoryRpcSessionPair<T, C>.
  • After disconnect(): the test checks that pending calls reject with the same reason, that onRpcBroken fires on a stub the server holds, and that a second disconnect() doesn't throw.

I checked it locally:

  • pnpm test:types passes.
  • The new in-memory session pair tests and the rewritten limits backwards compatibility tests pass in the node vitest project.
  • Leaving out T still infers correctly, for both an RpcTarget subclass and a plain object main. For example, newInMemoryRpcSessionPair(new Echo()).stub.echo(1) is typed number, and calling an unknown method is an error.

The transport logic is still correct:

  • send() hands the message straight to a waiting receiver, or queues it.
  • receive() delivers any queued messages before it rejects, so an abort message sent just before transport.abort() still reaches the peer.
  • abort() breaks both ends. A second call does nothing because #peer has already been cleared.

I have one non-blocking nit, left inline. Looks good to merge from my side.

Comment thread src/index.ts Outdated
@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown

I reviewed the latest commit (d6d6cca) and posted a review: #276 (review). I think it's ready to merge.

The earlier review's four comments are all addressed. That review looked at an older commit (d8a7fef):

  • Function mains: a function passed as main now has to match the whole function type, not just part of it. Type tests that are expected to fail confirm this.
  • Client main: there's now an optional clientMain, typed by a second type parameter, so the server can call back into the client's main object.
  • Return type: the function now returns a named InMemoryRpcSessionPair<T, C> type.
  • After disconnect(): the new test checks that pending calls reject with the given reason, that stubs held by either side get their broken callback, and that calling disconnect() twice doesn't throw.

What I ran:

  • pnpm test:types passes.
  • The new in-memory tests and the rewritten limits.test.ts tests pass in the node vitest project. I didn't run the workerd or browser projects.
  • Leaving out the type parameter still gives correctly typed stubs, for both an RpcTarget subclass and a plain object.

Transport logic: I re-checked the transport and it's still correct. An abort message sent just before shutdown still reaches the other side, disconnect() breaks both ends, and calling it again does nothing.

One small suggestion, not blocking: options still contains clientMain when it's passed to both sessions. That's harmless now, because sessions only read onSendError and limits. I suggested pulling clientMain out with destructuring before passing the options on. I first attached that suggestion to the wrong line, so I deleted it and reposted it as a separate comment covering both lines it replaces: #276 (comment). As a result, it isn't grouped under the review.

github run

@ndisidore
ndisidore force-pushed the feat/in-memory-session-pair branch from d6d6cca to b954a5c Compare September 24, 2026 23:13
@dimitropoulos

Copy link
Copy Markdown
Collaborator

thoughts:

  • we should definitely do this!
  • should this be exported from a separate subpath so we can treeshake it for people that don't need it (i.e. non-testing)?
  • should we export InMemoryTransport itself so people can get in a little deeper to mess with it (add delays, drop messages, etc.)
  • this would be a cool thing to have an example for (as in, the 4th playground)
  • moar tests for more structured-clone things is nice to have

@ndisidore

Copy link
Copy Markdown
Member Author
  • we should definitely do this!

💪

  • should this be exported from a separate subpath so we can treeshake it for people that don't need it (i.e. non-testing)?

nah, it already tree-shakes. i bundled a tiny app against dist with esbuild that only imports newWebSocketRpcSession, and none of the pair's code ends up in the output. if you do import it, it's ~600 bytes minified.
a subpath would also make things worse. tsdown builds every entry self-contained, so capnweb/testing would ship its own copy of the core, and stubs from that copy fail with "Cannot serialize value" in sessions from the main one. that's the exact bug that kicked this whole thing off lol

  • should we export InMemoryTransport itself so people can get in a little deeper to mess with it (add delays, drop messages, etc.)

i'd hold off on that one. once InMemoryTransport is exported, pair() and the abort/drop behavior are public API forever

  • moar tests for more structured-clone things is nice to have

fwiw the pair sends JSON strings (since this is the predominate encoding. iirc only MessagePort transport uses structed clone), not structured clones, so structured-clone tests wouldn't go through it anyway.
if you wanted though, we could add an encoding option to the pair (also would be a non-breaking change to add later)

Comment thread packages/docs/src/content/docs/guides/testing.mdx
@ndisidore
ndisidore force-pushed the feat/in-memory-session-pair branch from b954a5c to f974469 Compare September 25, 2026 12:49
Comment thread packages/docs/src/content/docs/guides/testing.mdx
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx Outdated
Comment thread packages/docs/src/content/docs/guides/testing.mdx
Comment thread __type-tests__/rpc-types.test.ts
@ndisidore
ndisidore force-pushed the feat/in-memory-session-pair branch from f974469 to a6de425 Compare September 25, 2026 16:51

This branch has not been deployed

No deployments
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.

3 participants