Typed Design Spec
Title
Durable Object–hosted Cap’n Web RPC for share sessions
Context
You chose Option B: make the Durable Object the Cap’n Web RPC peer.
That means the design goal changes from:
- “Worker hosts
ShareRpcImpl and may call DO internally”
to:
- “Client Cap’n Web stub connects through the Worker to a Durable Object that hosts the Cap’n Web session”
This is viable, but it is a real architectural change, not just a routing tweak.
Goals
- Host the Cap’n Web RPC session inside a Durable Object
- Allow the client to establish a WebSocket RPC session via the Worker
- Preserve a type-safe
ShareRpc contract
- Support local testing in a capnweb-style, in-process manner before relying on Wrangler dev
Non-Goals
- I am not implementing the change
- I am not collapsing the existing viewer
/ws/:id protocol into RPC unless explicitly chosen
- I am not performing git operations
Architecture Summary
Chosen architecture
Client newWebSocketRpcSession()
-> Worker route /rpc/share/:scope (upgrade)
-> Durable Object fetch()
-> DO upgrades WebSocket
-> DO hosts Cap’n Web session
-> DO exposes ShareRpc target
Key boundary decision
The Durable Object becomes the authoritative remote ShareRpc server.
Implication
The current ShareRpcImpl in packages/cloudsession/src/rpc.ts should either:
- move conceptually into the DO implementation, or
- be split into pure storage/business logic plus a DO-hosted RPC adapter
I strongly recommend the second.
Modules and boundaries
- Worker router
- Responsibility:
- authenticate/route initial request
- resolve which DO instance should receive the upgraded request
- proxy
fetch() to that DO
- Should not contain share business logic once Option B is complete
- Durable Object RPC host
- Responsibility:
- receive upgraded request
- host Cap’n Web WebSocket session
- expose
ShareRpc methods
- coordinate persistence/broadcasts
- Storage logic
- Responsibility:
- create/update/delete session and index records in R2
- pure domain logic where possible
- Keep separate from transport host for testability
- Optional viewer/broadcast socket
- If
/ws/:id remains for browser live updates, that is a separate protocol and should stay separate unless intentionally replaced
Goals / Non-Goals
Goals
- WebSocket RPC session terminates in DO
ShareRpc implemented at DO boundary
- deterministic local test can prove one real RPC call works
- clear mapping from request route to DO identity
Non-Goals
- no mixed transport ambiguity
- no duplicate ownership between Worker and DO for the same RPC interface
- no accidental reuse of non-RPC broadcast socket as Cap’n Web transport
Types and Data Models
Shared RPC contract
export interface ShareRpc extends RpcTarget {
createShare(sessionID: string, initialData?: SyncData[]): Promise<SyncInfo>
syncShare(shareID: string, secret: string, data: SyncData[]): Promise<{ success: boolean; syncCount: number }>
deleteShare(shareID: string, secret: string): Promise<{ success: boolean }>
probeValue(input: ProbeValueInput): ProbeValueOutput
probeCallback(cb: ProbeCallback): Promise<string>
}
Durable Object routing identity
You now need a routing key for the DO session host.
Recommended choices:
global-share-rpc
- one DO instance acts as the root RPC host for share creation/update/delete
- simplest bootstrap model
shareID
- one DO per share
- good for per-share long-lived ownership
- awkward for
createShare(sessionID) because the shareID does not exist until after create
sessionID
- one DO per original session
- possible, but less aligned with current external API
Recommendation
Use a single root RPC DO first:
type ShareRpcScope = "root"
Reason:
createShare() needs a stable entry point before a share exists
- simpler client bootstrapping
- easiest local testing
- avoids needing a pre-create routing indirection
Then, if needed later, the root RPC can return references/IDs for per-share DOs.
Recommended internal decomposition
Split transport from domain logic:
interface ShareStore {
createShare(sessionID: string, initialData?: SyncData[]): Promise<SyncInfo>
syncShare(shareID: string, secret: string, data: SyncData[]): Promise<{ success: boolean; syncCount: number }>
deleteShare(shareID: string, secret: string): Promise<{ success: boolean }>
}
DO RPC target:
class ShareRpcTarget extends RpcTarget implements ShareRpc {
constructor(private store: ShareStore) { super() }
}
This avoids putting all persistence logic directly into the transport class.
Public Interfaces and Contracts
Worker route contract
GET/POST /rpc/share
Upgrade: websocket
-> forwarded to root ShareRpc Durable Object
Preconditions
- Request path matches configured RPC route
- If auth is configured, auth is validated before proxying
- Request is a valid Cap’n Web session initiation request
Postconditions
- On success, response is a valid WebSocket upgrade handled by the DO
- The DO hosts the Cap’n Web session main interface
Durable Object RPC contract
- The DO must expose
ShareRpc as the Cap’n Web main target
- The DO must be able to access:
- R2 bucket binding
- shared secret config
- API domain
- any broadcast facilities needed
Important invariant
If the client uses:
newWebSocketRpcSession<ShareRpc>(...)
then the DO must actually host:
newWebSocketRpcSession(webSocket, shareRpcTarget)
or equivalent Cap’n Web server-side session initialization against the already-open socket.
Error Model
Structured errors
type ShareRpcError =
| { type: "Unauthorized" }
| { type: "NotFound"; shareID: string }
| { type: "InvalidSecret"; shareID: string }
| { type: "RouteMismatch"; route: string }
| { type: "ProtocolMismatch"; expected: "capnweb-websocket" }
| { type: "StorageFailure"; operation: "create" | "sync" | "delete"; cause: string }
| { type: "UpgradeFailed"; reason: string }
Behavioral guidance
- auth failure should happen at Worker boundary before DO handoff when possible
- share existence/secret failures belong in the RPC target
- transport/protocol mismatch should fail early and clearly
Design Decisions
Decision 1 — Root RPC DO
Use one root DO as the Cap’n Web session entry point.
Why
createShare() requires a stable main interface
- avoids pre-creation routing paradox
- reduces complexity
Decision 2 — Separate viewer socket from RPC socket
Do not reuse /ws/:id for Cap’n Web RPC unless you fully replace that protocol.
Why
- existing
/ws/:id appears to be app-specific broadcast/live update semantics
- Cap’n Web framing/protocol is distinct
- conflating them increases correctness risk
Decision 3 — WebSocket-only RPC for Option B
If DO is the RPC host, prefer:
- client:
newWebSocketRpcSession
- server: DO-hosted WebSocket Cap’n Web session
Do not mix this with newHttpBatchRpcSession for the same path unless explicitly designed.
Example Usage
Client
using rpc = newWebSocketRpcSession<ShareRpc>(`${baseUrl}/rpc/share`)
const info = await rpc.createShare(sessionID, initialData)
await rpc.syncShare(info.id, info.secret, items)
await rpc.deleteShare(info.id, info.secret)
Worker
if (url.pathname === "/rpc/share") {
const id = env.SHARE_RPC.idFromName("root")
const stub = env.SHARE_RPC.get(id)
return stub.fetch(request)
}
DO host
class ShareRpcHost extends DurableObject {
fetch(request: Request) {
// validate upgrade
// accept WebSocket
// host Cap’n Web session with ShareRpc target
}
}
Acceptance Criteria
Observable, testable
- A WebSocket request to
/rpc/share is proxied by the Worker to the root DO.
- The root DO hosts a Cap’n Web RPC session implementing
ShareRpc.
probeValue() succeeds over a real local WebSocket RPC session.
probeCallback() succeeds, proving bidirectional reference passing works.
createShare() persists expected share/ and index/ entries.
syncShare() updates persisted state and increments syncCount.
deleteShare() removes persisted records.
- If unauthorized, the request fails before or at the DO boundary deterministically.
- Local tests distinguish:
- RPC route semantics
- viewer/broadcast route semantics
Risks & Open Questions
High-risk areas
- Bootstrap/routing complexity
- You need a stable root DO entry point
- per-share DO routing cannot be the only design if
createShare() is a root operation
- Protocol confusion
- Existing
/ws/:id may tempt reuse
- do not reuse unless it truly becomes Cap’n Web transport
- Storage and concurrency
- A single root DO may serialize RPC entry handling
- acceptable initially, but could become a throughput bottleneck later
- Version skew
packages/opencode uses capnweb@0.4.0
packages/cloudsession uses capnweb@0.6.1
- this is a serious compatibility risk for a WebSocket session design
- I would treat version unification as required, not optional
- Auth model
- For CLI/non-browser WebSocket clients, custom headers may work depending on runtime
- for browser clients, capnweb guidance is to authenticate in-band
- you need to state which client class consumes this RPC
Critical open questions
- Is this RPC consumed only by OpenCode CLI/runtime, or also by browsers?
- Should the root DO also handle broadcast viewer connections, or stay RPC-only?
- Do you want one root DO forever, or root DO returning per-share remote objects later?
- Will you unify capnweb version across
packages/opencode and packages/cloudsession first?
Correctness-First Review of This Decision
Summary
Option B is valid, but it requires a deliberate redesign:
- root DO as RPC host
- separate RPC route
- separate viewer route
- version alignment
- local test proving actual WebSocket RPC exchange
Approval Status
Approve with required design constraints
Required Changes
- Introduce a dedicated RPC DO host
- Use one stable root route and one stable root DO identity
- Do not treat current
/ws/:id broadcast route as the RPC transport unless intentionally replaced
- Unify capnweb versions across client and server packages
- Add a real local RPC test for probe and create/sync/delete flows
Suggested Interface/Type Adjustments
- Keep
ShareRpc as the only remote main interface
- Extract persistence logic into a transport-agnostic store/service
- Add an explicit DO binding dedicated to RPC host, separate from broadcast if needed
Delegation Plan
Implementation task 1
Create a new root Durable Object class to host the Cap’n Web RPC main interface.
Acceptance:
- DO has a single root identity path
- Worker can proxy
/rpc/share to it
Implementation task 2
Refactor ShareRpcImpl into transport-agnostic share domain/store logic plus a DO-hosted RpcTarget adapter.
Acceptance:
- domain logic testable without socket transport
- RPC class only adapts contract to domain operations
Implementation task 3
Align client transport to WebSocket RPC for the share API.
Acceptance:
ShareNext uses newWebSocketRpcSession<ShareRpc>() for RPC mode
- route path matches Worker route exactly
Implementation task 4
Unify capnweb version across packages/opencode and packages/cloudsession
Acceptance:
- same semver in both packages
- install/build succeeds
- no mixed-version runtime
Tests task 1
Add local in-process RPC tests for:
probeValue
probeCallback
createShare
syncShare
deleteShare
Acceptance:
- tests fail on route/transport/method mismatch
- tests use fake bindings and real app/DO logic where practical
Tests task 2
Keep separate tests for existing /ws/:id viewer socket semantics
Acceptance:
- no ambiguity between broadcast socket and RPC socket
Docs task
Document:
/rpc/share = Cap’n Web RPC to root DO
/ws/:id = viewer/broadcast socket
- auth model
- local test workflow
Acceptance:
- route purposes explicit
- no mixed-protocol ambiguity
Git task
Branch:
feat/share-rpc-do-host
Commits:
cloudsession: add root durable object capnweb rpc host
opencode: switch share rpc client to websocket session
cloudsession: add local capnweb rpc tests
docs: clarify rpc vs viewer websocket routes
Acceptance:
- commits scoped by concern
QA Self-Check Results
- Exhaustiveness: covered bootstrap, routing, transport, storage, auth, version skew
- Contracts: explicit root DO RPC contract defined
- Effects: Worker vs DO responsibilities isolated
- Types: shared interface retained, transport adapter separation recommended
- Compatibility: version skew flagged as a major risk
- Security: auth boundary called out
- Observability: concrete acceptance criteria provided
- Standards: correctness-first, no implementation code
- Delegation: tasks complete and testable
Bottom line
Yes — Option B can work, but only if you make the Durable Object an actual Cap’n Web RPC host, with a root entry point.
The most important next moves are:
- dedicate a root DO to RPC hosting,
- keep
/rpc/share separate from /ws/:id,
- unify capnweb versions,
- add a local in-process WebSocket RPC test before depending on Wrangler dev.