Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/capnweb-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"agents": minor
---

Add a Cap'n Web connection transport to the WebSockets capability.

A connection can opt into a second wire (`?__agents_transport=capnweb`):
the same frames travel over a single Cap'n Web RPC session whose root
carries exactly one method — the message pipe. The capability's
handlers are transport-agnostic: both kinds of connection dispatch the
same `onConnect`/`onMessage`/`onClose`, appear in `getConnections()`,
and propagate `connection.close(code, reason)` to the client.

Because `Agent` rides the capability, `useAgent({ transport: "capnweb" })`
works against any Agent with the hook surface unchanged — identity,
state sync, `call`/`stub` RPC frames, and chat all flow over the pipe,
with reconnection and terminal-close semantics matching the PartySocket
path. The pipe client is internal to the hook — there is no new public
client surface.

Cap'n Web transport connections are non-hibernating: the Durable Object
stays pinned in memory while one is open.
25 changes: 25 additions & 0 deletions docs/agents/client-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ useAgent({
});
```

### Transport

`useAgent` speaks the Agent protocol over one of two wires. The default,
`"hibernating"`, is a plain WebSocket served by the Durable Object's
hibernation API. The experimental `"capnweb"` transport carries the same
frames over a single [Cap'n Web](https://github.com/cloudflare/capnweb) RPC
session:

```typescript
useAgent({
agent: "ChatAgent",
name: "room-123",
transport: "capnweb" // default: "hibernating"
});
```

Everything else about the hook is identical on both transports — identity,
state sync, `call`/`stub`, chat, reconnection with backoff, and terminal close
codes. Choose `"capnweb"` when you want the connection to share a Cap'n Web
session's semantics (ordered, promise-pipelined delivery over one RPC
session). Its trade-off is that the connection does not hibernate: the
Durable Object stays in memory while a Cap'n Web connection is open.
`transport` is a hook option only; there is no separate client class.

### Async Query Parameters

For authentication tokens or other async data, pass a function that returns a Promise:
Expand Down Expand Up @@ -351,6 +375,7 @@ type UseAgentOptions<State> = {
name?: string; // Instance name (default: "default")
host?: string; // Custom host
path?: string; // Custom path prefix
transport?: "hibernating" | "capnweb"; // Wire (default: "hibernating")

// Query parameters
query?:
Expand Down
21 changes: 16 additions & 5 deletions docs/agents/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,22 @@ no new surface for this — its `@callable()`-decorated methods are its
interface, served on every wire: natively over the legacy JSON RPC protocol
and, through the decorator-derived target, over the Cap'n Web endpoint.

Connections use Cloudflare's WebSocket Hibernation API. Idle clients remain
connected while the Durable Object can leave memory; when a message wakes the
object, its constructor and lifecycle startup run again before `onMessage`.
State needed after a wake must be stored durably or through
`connection.setState()`. There is no non-hibernating mode.
Connections speak one of two wire transports, chosen by the client:

- **Hibernating WebSocket** (default) uses Cloudflare's WebSocket Hibernation
API. Idle clients remain connected while the Durable Object can leave
memory; when a message wakes the object, its constructor and lifecycle
startup run again before `onMessage`. State needed after a wake must be
stored durably or through `connection.setState()`.
- **Cap'n Web** (`?__agents_transport=capnweb`, or `useAgent({ transport:
"capnweb" })`) carries the same frames over a single Cap'n Web RPC session.
The connection is an in-memory socket and does not hibernate: the Durable
Object stays pinned while it is open, and the connection is gone after an
eviction, so clients reconnect.

The handlers are transport-agnostic. Both kinds of connection dispatch the
same `onConnect`/`onMessage`/`onClose`/`onError`, appear in
`getConnections()`, and honour `connection.close(code, reason)`.

## Native RPC

Expand Down
163 changes: 163 additions & 0 deletions packages/agents/src/react-tests/useAgent-capnweb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Integration tests for useAgent's Cap'n Web transport
* (`transport: "capnweb"`) against a real miniflare worker.
*
* The transport must be behaviorally identical to the default
* hibernating transport: identity, state sync, and RPC calls all run
* over the single Cap'n Web socket.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render as _render, cleanup } from "vitest-browser-react";
import { Suspense, useEffect } from "react";
import { useAgent, type UseAgentOptions } from "../react";
import { getTestWorkerHost } from "./test-config";

// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- tests don't need strict agent typing
type TestAgent = ReturnType<typeof useAgent<any>>;

const render: typeof _render = async (...args) => {
const result = await _render(...args);
// @ts-expect-error - globalThis is not typed
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
return result;
};

afterEach(() => {
cleanup();
});

function SuspenseWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<div>loading</div>}>{children}</Suspense>;
}

function TestAgentComponent<State = unknown>({
options,
onAgent
}: {
options: UseAgentOptions<State>;
onAgent: (agent: ReturnType<typeof useAgent<State>>) => void;
}) {
const agent = useAgent<State>(options);

useEffect(() => {
onAgent(agent);
}, [agent, agent.identified, agent.state, onAgent]);

return (
<div data-testid="agent-status">
{agent.identified ? "connected" : "connecting"}
</div>
);
}

async function waitForConnected(container: Element) {
await vi.waitFor(
() => {
const status = container.querySelector('[data-testid="agent-status"]');
expect(status?.textContent).toBe("connected");
},
{ timeout: 10000 }
);
}

describe("useAgent with transport: capnweb", () => {
it("connects and receives identity", async () => {
const { host, protocol } = getTestWorkerHost();
let capturedAgent: TestAgent | null = null;

const { container } = await render(
<SuspenseWrapper>
<TestAgentComponent
options={{
agent: "TestStateAgent",
name: "capnweb-hook-identity",
host,
protocol,
transport: "capnweb"
}}
onAgent={(agent: TestAgent) => {
capturedAgent = agent;
}}
/>
</SuspenseWrapper>
);

await waitForConnected(container);
expect(capturedAgent!.identified).toBe(true);
expect(capturedAgent!.name).toBe("capnweb-hook-identity");
expect(capturedAgent!.agent).toBe("test-state-agent");
});

it("receives initial state and round-trips setState", async () => {
const { host, protocol } = getTestWorkerHost();
const onStateUpdate = vi.fn();
let capturedAgent: TestAgent | null = null;

const { container } = await render(
<SuspenseWrapper>
<TestAgentComponent
options={{
agent: "TestStateAgent",
name: `capnweb-hook-state-${crypto.randomUUID()}`,
host,
protocol,
transport: "capnweb",
onStateUpdate
}}
onAgent={(agent: TestAgent) => {
capturedAgent = agent;
}}
/>
</SuspenseWrapper>
);

await waitForConnected(container);

// Initial state arrives from the server over the Cap'n Web pipe.
await vi.waitFor(() => {
expect(onStateUpdate).toHaveBeenCalledWith(
expect.objectContaining({ count: 0 }),
"server"
);
});

// setState travels client → server over the same pipe.
capturedAgent!.setState({ count: 5, items: ["a"], lastUpdated: null });
await vi.waitFor(() => {
expect(onStateUpdate).toHaveBeenCalledWith(
expect.objectContaining({ count: 5 }),
"client"
);
});
});

it("performs RPC calls through call() and stub", async () => {
const { host, protocol } = getTestWorkerHost();
let capturedAgent: TestAgent | null = null;

const { container } = await render(
<SuspenseWrapper>
<TestAgentComponent
options={{
agent: "TestCallableAgent",
name: "capnweb-hook-rpc",
host,
protocol,
transport: "capnweb"
}}
onAgent={(agent: TestAgent) => {
capturedAgent = agent;
}}
/>
</SuspenseWrapper>
);

await waitForConnected(container);

await expect(capturedAgent!.call("add", [2, 3])).resolves.toBe(5);
await expect(capturedAgent!.stub.add(4, 5)).resolves.toBe(9);
await expect(capturedAgent!.call("throwError", ["boom"])).rejects.toThrow(
"boom"
);
});
});
Loading
Loading