OK, for the share-next.ts Initial connection, I want it to follow some basic websocket security, so that the initial request generated by
const shareSession = newWebsocketRpcSession( "wss://opencode.j9xym/rpc" ), where shareSession is the returned RpcStub object. I'm not sure how to set the headers on the request but thats what we need to do.
Have a look at:
- @packages/cloudsession/src/rpc.ts and the tests
- github.com/cloudflare/capnweb/README.md, PROTOCOL.md, tests, examples
- /skill cloudflare
We also want to resolve the old way of collecting the session info which is saved by drizzle and used for the shareID and URL.
Called the Read tool with the following input: {"filePath":"/home/jm/data/code/opencode/packages/cloudsession/src/rpc.ts"}
/home/jm/data/code/opencode/packages/cloudsession/src/rpc.ts
file
1: import { RpcTarget } from "capnweb"
2: import { v5 as uuidv5 } from "uuid"
3: import type { SessionBroadcast as SessionBroadcastType } from "./broadcast.ts"
4: import type { ProbeCallback, ProbeValueInput, ProbeValueOutput } from "./rpc-contract.ts"
5: import { createStorageAdapter, type StorageAdapter } from "./storage.ts"
6: import type { AgentSession, SessionIndex, SyncData, SyncInfo } from "./types.ts"
7:
8: type Env = {
9: SESSIONS_STORE: R2Bucket
10: SESSIONS_SHARED_SECRET: string
11: API_DOMAIN: string
12: SESSIONS_BROADCAST: DurableObjectNamespace
13: }
14:
15: export class ShareRpcImpl extends RpcTarget {
16: private readonly sessions: StorageAdapter
17: private readonly index: StorageAdapter
18:
19: constructor(private env: Env) {
20: super()
21: this.sessions = createStorageAdapter(env.SESSIONS_STORE)
22: this.index = createStorageAdapter(env.SESSIONS_STORE)
23: }
24:
25: async createShare(sessionID: string, initialData?: SyncData[]): Promise {
26: const shareID = sessionID.slice(-8)
27: const secret = uuidv5(sessionID, this.env.SESSIONS_SHARED_SECRET)
28: const now = Date.now()
29: const info: SyncInfo = {
30: id: shareID,
31: secret,
32: url: https://${this.env.API_DOMAIN}/share/${shareID},
33: }
34:
35: const initial: AgentSession = {
36: session: {
37: id: sessionID,
38: slug: sessionID,
39: projectID: "",
40: directory: "",
41: title: "",
42: version: "1",
43: time: {
44: created: now,
45: updated: now,
46: },
47: },
48: messages: [],
49: parts: [],
50: diffs: [],
51: models: [],
52: metadata: {
53: createdAt: now,
54: lastUpdated: now,
55: syncCount: 0,
56: secret,
57: sessionID,
58: },
59: }
60:
61: // Apply any initial data provided (pipeline create+sync into one round trip)
62: if (initialData && initialData.length > 0) {
63: applyData(initial, initialData)
64: initial.metadata.syncCount = 1
65: }
66:
67: const initialIndex: SessionIndex = {
68: id: shareID,
69: sessionID,
70: title: initial.session.title,
71: directory: initial.session.directory,
72: messageCount: initial.messages.length,
73: partCount: initial.parts.length,
74: diffCount: initial.diffs.length,
75: modelCount: initial.models.length,
76: lastUpdated: now,
77: syncCount: initial.metadata.syncCount,
78: createdAt: now,
79: }
80:
81: await Promise.all([
82: this.sessions.put(share/${shareID}, initial),
83: this.index.put(index/${shareID}, initialIndex),
84: ])
85: return info
86: }
87:
88: async syncShare(shareID: string, secret: string, data: SyncData[]) {
89: const agentSession = await this.sessions.get(share/${shareID})
90: if (!agentSession) {
91: throw new Error("Share not found")
92: }
93:
94: if (agentSession.metadata.secret !== secret) {
95: throw new Error("Invalid secret")
96: }
97:
98: const now = Date.now()
99: const next: AgentSession = {
100: ...agentSession,
101: metadata: {
102: ...agentSession.metadata,
103: lastUpdated: now,
104: syncCount: agentSession.metadata.syncCount + 1,
105: },
106: }
107:
108: applyData(next, data)
109:
110: const updatedIndex: SessionIndex = {
111: id: shareID,
112: sessionID: next.session.id,
113: title: next.session.title,
114: directory: next.session.directory,
115: messageCount: next.messages.length,
116: partCount: next.parts.length,
117: diffCount: next.diffs.length,
118: modelCount: next.models.length,
119: lastUpdated: now,
120: syncCount: next.metadata.syncCount,
121: createdAt: next.metadata.createdAt,
122: }
123:
124: await Promise.all([this.sessions.put(share/${shareID}, next), this.index.put(index/${shareID}, updatedIndex)])
125:
126: const doID = this.env.SESSIONS_BROADCAST.idFromName(shareID)
127: const stub = this.env.SESSIONS_BROADCAST.get(doID)
128: await stub.broadcast(data)
129:
130: return { success: true, syncCount: next.metadata.syncCount }
131: }
132:
133: async deleteShare(shareID: string, secret: string): Promise<{ success: boolean }> {
134: const agentSession = await this.sessions.get(share/${shareID})
135: if (!agentSession) {
136: throw new Error("Share not found")
137: }
138:
139: if (agentSession.metadata.secret !== secret) {
140: throw new Error("Invalid secret")
141: }
142:
143: await Promise.all([this.sessions.delete(share/${shareID}), this.index.delete(index/${shareID})])
144:
145: return { success: true }
146: }
147:
148: probeValue(input: ProbeValueInput): ProbeValueOutput {
149: return {
150: when: input.when.toISOString(),
151: bytes: Array.from(input.bytes),
152: size: input.bytes.byteLength,
153: nested: input.nested,
154: }
155: }
156:
157: async probeCallback(cb: ProbeCallback): Promise {
158: return await cb("server-called")
159: }
160: }
161:
162: /**
163: * Apply a batch of sync data items to an AgentSession in place.
164: * Extracted so createShare and syncShare share the same merge logic.
165: */
166: function applyData(session: AgentSession, data: SyncData[]): void {
167: for (const item of data) {
168: if (item.type === "session") {
169: session.session = item.data
170: continue
171: }
172:
173: if (item.type === "message") {
174: const idx = session.messages.findIndex((m) => m.id === item.data.id)
175: if (idx === -1) {
176: session.messages.push(item.data)
177: } else {
178: session.messages[idx] = item.data
179: }
180: continue
181: }
182:
183: if (item.type === "part") {
184: const idx = session.parts.findIndex((p) => p.id === item.data.id)
185: if (idx === -1) {
186: session.parts.push(item.data)
187: } else {
188: session.parts[idx] = item.data
189: }
190: continue
191: }
192:
193: if (item.type === "session_diff") {
194: session.diffs = [...session.diffs, ...item.data]
195: continue
196: }
197:
198: if (item.type === "model") {
199: for (const model of item.data) {
200: const idx = session.models.findIndex((m) => m.id === model.id)
201: if (idx === -1) {
202: session.models.push(model)
203: } else {
204: session.models[idx] = model
205: }
206: }
207: }
208: }
209: }
(End of file - total 209 lines)
Instructions from: /home/jm/data/code/opencode/packages/cloudsession/AGENTS.md
# packages/cloudsession
Cloudflare Worker that stores and serves shared OpenCode agent sessions. Provides a JSON API for the CLI and server-rendered HTML views for browsers.
Build / Test / Deploy
# Run all tests (40 tests across 3 files)
bun test --preload ./src/preload.ts src/**/*.test.ts
# Run specific test suites
bun test --preload ./src/preload.ts src/api.test.ts
bun test --preload ./src/preload.ts src/storage.test.ts
bun test --preload ./src/preload.ts src/index.test.ts
# Typecheck
bun tsc --noEmit
# Deploy (requires CLOUDFLARE_API_TOKEN and wrangler secret for SESSIONS_SHARED_SECRET)
CLOUDFLARE_API_TOKEN=... bunx wrangler deploy
# Local dev
bunx wrangler dev
The --preload ./src/preload.ts flag is required because bun:test needs a mock for the cloudflare:workers module (used by the Durable Object import).
Architecture
CLI (packages/opencode) Browser
| |
| POST /api/share | GET /sessions (HTML list)
| POST /api/share/:id/sync | GET /share/:id (HTML detail)
| DELETE /api/share/:id | GET /ws/:id (WebSocket)
| |
+----------> Hono app (src/index.tsx) <----+
| |
v v
R2 Bucket Durable Object
(storage) (SessionBroadcast)
Hono handles all HTTP routing. R2 stores session data. A Durable Object (SessionBroadcast) manages WebSocket connections for live updates -- when the CLI syncs new data, the DO broadcasts it to connected browser viewers.
R2 Storage Layout
The R2 bucket (opencode-sessions) uses two key prefixes:
| Prefix |
Type |
Purpose |
share/${id} |
AgentSession |
Full session blob (messages, parts, diffs, models, metadata) |
index/${id} |
SessionIndex |
Lightweight metadata (title, counts, timestamps) |
Why dual storage? Workers have a 128MB memory limit. Loading every full AgentSession blob just to list sessions hits that limit at ~80 sessions. The index/ prefix stores only the fields needed for listing, so GET /sessions and GET /api/sessions stay lightweight.
Both prefixes are written on every POST /api/share (create) and POST /api/share/:id/sync (update), and both are deleted on DELETE /api/share/:id.
API Routes
| Method |
Path |
Auth |
Description |
| POST |
/api/share |
none |
Create a new share, returns { id, url, secret } |
| POST |
/api/share/:id/sync |
secret (body) |
Sync session data, broadcasts to WebSocket viewers |
| GET |
/api/share/:id |
none |
Get full AgentSession blob |
| GET |
/api/share/:id/metadata |
none |
Get SessionIndex entry |
| DELETE |
/api/share/:id |
secret (body) |
Delete session and index |
| GET |
/api/sessions |
none |
List all sessions (from index prefix) |
HTML Routes
| Method |
Path |
Description |
| GET |
/ |
Redirects to /sessions |
| GET |
/sessions |
Session list with client-side search |
| GET |
/share/:id |
Session detail with markdown rendering |
| GET |
/ws/:id |
WebSocket upgrade, proxied to Durable Object |
Views use Hono JSX (server-rendered). Inline CSS, no external stylesheets. Dark theme (#0a0a0a background, #6ee7b7 accent). The session detail page includes an inline <script> that connects to /ws/:id for live updates.
File Structure
src/
index.tsx Main Hono app (API + HTML routes, DO re-export)
types.ts Type definitions (AgentSession, SessionIndex, SyncData, etc.)
storage.ts R2 StorageAdapter (generic, typed) + MockStorageAdapter for tests
broadcast.ts SessionBroadcast Durable Object (WebSocket hibernation API)
preload.ts Test preload mock for cloudflare:workers module
api.test.ts API endpoint tests (28 tests)
index.test.ts Original tests
storage.test.ts Storage adapter tests
views/
layout.tsx Base HTML layout component
session-list.tsx Session list page
session-detail.tsx Session detail with marked for markdown
not-found.tsx 404 page
script/
backfill-index.ts One-time migration: backfills index/ entries from share/ blobs via S3 API
wrangler.jsonc Worker config (R2, DO bindings, migrations, routes)
Types
Types are defined in src/types.ts. SDK types (Session, Message, Part, FileDiff, Model) are re-exported from @opencode-ai/sdk/v2.
Key local types:
AgentSession -- full session blob stored at share/${id}
SessionIndex -- lightweight index entry stored at index/${id}
SessionMetadata -- internal metadata (secret, syncCount, timestamps)
SyncData -- discriminated union for sync payloads
SyncInfo / ShareCredentials -- share creation response types
Relationship to Other Packages
packages/opencode -- the CLI. src/share/share-next.ts calls POST /api/share and POST /api/share/:id/sync to create and update shared sessions.
packages/sdk -- provides @opencode-ai/sdk/v2 types (Session, Message, Part, FileDiff, Model) that this package depends on. If SDK types change upstream, src/types.ts re-exports may need updating.
This package is fork-only -- it does not exist in the upstream repo (anomalyco/opencode). Merges from upstream will not conflict with files in packages/cloudsession/, but SDK type changes in packages/sdk/ could break the build.
Environment & Secrets
| Binding |
Type |
Source |
Description |
SESSIONS_STORE |
R2 Bucket |
wrangler.jsonc |
R2 bucket opencode-sessions |
SESSIONS_SHARED_SECRET |
Secret |
wrangler secret put |
Used to derive per-session secrets via UUIDv5 |
API_DOMAIN |
Var |
wrangler.jsonc |
Base URL for share links (not including proto, ie. "https://" ) |
SESSIONS_BROADCAST |
DO Namespace |
wrangler.jsonc |
Durable Object binding for SessionBroadcast |
Secrets are managed with wrangler secret put SESSIONS_SHARED_SECRET. The CLOUDFLARE_API_TOKEN for deploy is stored externally (GNU pass).
Cloudflare Notes
- Bot Fight Mode / WAF: Must be disabled or have a skip rule for the domain. Cloudflare's managed challenge pages will block programmatic access (CLI fetches, curl) with 403s.
- Durable Object migrations: Defined in
wrangler.jsonc under migrations. The SessionBroadcast class uses new_sqlite_classes (required for hibernation API). New DO classes need a new migration tag.
- Routes: Worker is bound to the configured domain via Cloudflare's zone route, with
workers_dev: false and preview_urls: false.
Upstream Sync
git remote add upstream https://github.com/anomalyco/opencode.git # if not already added
git fetch upstream
git merge upstream/dev
Since packages/cloudsession/ is fork-only, upstream merges won't touch this directory. Watch for:
- Changes to
@opencode-ai/sdk types in packages/sdk/ -- could break imports in src/types.ts
- Changes to
bun.lock or root package.json -- may need conflict resolution
- Changes to the share protocol in
packages/opencode/src/share/ -- API contract may need updating
Code Style
Follow the root AGENTS.md style guide:
- No
else statements, prefer early returns
- No
any type (except Zod z.any() for pass-through sync data validation)
- No
let, prefer const with ternaries
- No unnecessary destructuring, use dot notation
- Single-word variable names where possible
- Functional array methods (
filter, map, flatMap) over loops
- Inline values used only once