@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
/* BoardRoom — one Durable Object per canvas board.
|
|
2
|
+
*
|
|
3
|
+
* WHERE THIS LIVES, AND WHY IT MOVED. The class used to sit in realtime/src/index.js,
|
|
4
|
+
* deployed as its own worker per instance. It is here because Workers requires a Durable
|
|
5
|
+
* Object class to be a named export of the DEPLOYED module graph, and the engine worker
|
|
6
|
+
* is about to become that graph (`src/entry.js`). Both entry points import it from here,
|
|
7
|
+
* so there is one copy of the document authority rather than two that drift.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ THE STANDALONE REALTIME WORKER IS STILL LIVE, and this file did not change that.
|
|
10
|
+
* Every instance today runs `augur-realtime-<instance>` with its own ROOMS binding, and
|
|
11
|
+
* the engine's /__rt proxies to it. That worker keeps its `/room` route and keeps
|
|
12
|
+
* importing this class. Deleting the route — which `A-boardroom-port` describes — belongs
|
|
13
|
+
* with the instance that first serves rooms from its own engine worker, because doing it
|
|
14
|
+
* before then takes canvas multiplayer off three live sites.
|
|
15
|
+
*
|
|
16
|
+
*
|
|
17
|
+
* The worker upgrades /room?path=<boardPath>&name=<who> to a WebSocket and hands it to
|
|
18
|
+
* the room named by the board path (the same key the KV doc uses). The room is a relay
|
|
19
|
+
* plus THE document authority: it fans cursor moves / node ops / editing focus out to
|
|
20
|
+
* the other sockets, applies every op to its own copy of the doc, and persists.
|
|
21
|
+
*
|
|
22
|
+
* THE ROOM OWNS THE DOC (2026-08-07 — was "room persists while live" since 2026-07-27).
|
|
23
|
+
* The document's source of truth is the DO's OWN SQLite-backed storage: one row per node
|
|
24
|
+
* (`n:<id>`), one meta row (`m` — name, tombstones, clock). DO storage is strongly
|
|
25
|
+
* consistent and survives hibernation, so the old stash/docreq/cold-alarm dances are
|
|
26
|
+
* gone with the failure modes they papered over. Workers KV keeps the SAME doc under the
|
|
27
|
+
* SAME key (`board:<path>`, or `board:<workspace>:<path>` where the engine worker names
|
|
28
|
+
* the workspace — src/board-key.mjs) but demoted to a WRITE-THROUGH MIRROR: it serves the
|
|
29
|
+
* public GET /__board and the solo fallback, and the room writes it on the old cadence (45s
|
|
30
|
+
* dirty-alarm + flush on empty) — never reads it back except once, to migrate a
|
|
31
|
+
* pre-existing board into storage (lazy, first touch, per board).
|
|
32
|
+
*
|
|
33
|
+
* WHOSE BOARD IT IS arrives in a header, from the worker that owns the room binding, and
|
|
34
|
+
* is remembered durably (`ws`). A room reached through the standalone realtime worker gets
|
|
35
|
+
* no header and keeps the unscoped key — the deployment that has not moved its rooms has
|
|
36
|
+
* not moved its keys either, and those two halves are one cutover on purpose. On a miss
|
|
37
|
+
* the scoped read falls through to the unscoped key and the next mirror write lands it
|
|
38
|
+
* scoped, so a board that predates the segment is picked up by being opened.
|
|
39
|
+
*
|
|
40
|
+
* VERSIONED NODES (per-node last-writer-wins on a version int, not a CRDT). Every node carries
|
|
41
|
+
* `v` (int, bumped by whoever mutates it) and `vn` (random tiebreak). The room applies
|
|
42
|
+
* an op only if it's NEWER than what it holds (v, then vn); losers get a corrective op
|
|
43
|
+
* back so every client converges on the same winner. Deletes leave a tombstone
|
|
44
|
+
* (id → {v,t}) so a stale upsert can't resurrect a deleted node; tombs prune after
|
|
45
|
+
* TOMB_TTL. A client "seed" ({t:"doc"}) is RECONCILED per-node under the same rules —
|
|
46
|
+
* never adopted wholesale — so a stale tab (slept laptop, frozen tab, eventual-consistent
|
|
47
|
+
* KV read) can no longer revert a board, while its genuinely-new offline edits merge in.
|
|
48
|
+
* Legacy compat: v-less ops from old clients are accepted and stamped (live edits keep
|
|
49
|
+
* working); v-less nodes inside a SEED count as v0 (stale-tab protection is the point).
|
|
50
|
+
*
|
|
51
|
+
* Rooms under /__test/ never touch storage OR KV — pure RAM relay (Playwright isolation);
|
|
52
|
+
* they keep the old docreq dance since RAM is all they have.
|
|
53
|
+
*
|
|
54
|
+
* Uses the WebSocket Hibernation API, so an idle board with open tabs costs ~nothing.
|
|
55
|
+
* `doc` in memory is a cache rebuilt from storage on demand; `dirty` (KV mirror pending)
|
|
56
|
+
* is durable because the alarm outlives the instance that armed it.
|
|
57
|
+
*
|
|
58
|
+
* Protocol (JSON, one object per message):
|
|
59
|
+
* client→room: {t:"cursor",x,y,drag?}|{t:"cursor",gone:true} · {t:"ops",ops:[...]} ·
|
|
60
|
+
* {t:"focus",id|null} · {t:"sel",ids:[...]} (live selection) ·
|
|
61
|
+
* {t:"status",text,state} (persistent work state under an agent cursor:
|
|
62
|
+
* state working|idle|attention; kept on the attachment for late joiners) ·
|
|
63
|
+
* {t:"chat",text} (cursor chat — pure ephemeral relay) ·
|
|
64
|
+
* {t:"view",v:{x,y,s,w,h}} (live viewport — pan/zoom/window; kept on the
|
|
65
|
+
* attachment so follow mode mirrors a peer the instant it starts) ·
|
|
66
|
+
* {t:"proto",id,ev} (demo sync in a live tile iframe) ·
|
|
67
|
+
* {t:"timer",do:"start"|"add"|"pause"|"resume"|"stop",ms?} ·
|
|
68
|
+
* {t:"music",do:"play"|"stop",track?,at?} (shared session — see below) ·
|
|
69
|
+
* {t:"doc",doc} (seed/merge offer — reconciled, see above)
|
|
70
|
+
* room→client: {t:"welcome",sid,color,peers,doc?,needDoc?,session?} · {t:"join"|"leave",peer} ·
|
|
71
|
+
* relayed cursor/ops/focus/sel/status/chat stamped with the sender's info ·
|
|
72
|
+
* {t:"session",timer,music} · {t:"doc",doc} · {t:"docreq"} (test rooms only)
|
|
73
|
+
* cursor.drag is the drag fast-path: [{id,x,y,w,h},…] geometry for nodes mid-drag,
|
|
74
|
+
* relayed verbatim on the cursor cadence (~20Hz) so remote drags glide instead of
|
|
75
|
+
* stepping at the 120ms ops tick. Ephemeral — the durable version rides the ops tick.
|
|
76
|
+
* Ops: {op:"upsert",node} · {op:"del",id,v?} · {op:"name",name,v?}. Accepted ops are
|
|
77
|
+
* broadcast (with room-stamped versions where the sender sent none); rejected ops earn
|
|
78
|
+
* the SENDER a corrective ops message carrying the winning state. The doc's `view` is
|
|
79
|
+
* per-user viewport — never synced (a fossil in old mirrors; clients use localStorage).
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
// The mirror's key, built by the SAME function the /__board rail builds it with. Two
|
|
83
|
+
// writers, one spelling — see src/board-key.mjs.
|
|
84
|
+
import { boardKvKey, RT_WORKSPACE_HEADER } from "./board-key.mjs";
|
|
85
|
+
import { verifyRoomTicket } from "./room-ticket.mjs";
|
|
86
|
+
|
|
87
|
+
const MAX_MSG = 8 * 1024 * 1024; // a node op can carry an inlined image; KV caps the doc at 20MB
|
|
88
|
+
// Frozen: a fixed palette, read by index and never written. Freezing at the declaration
|
|
89
|
+
// is what makes it provably not per-isolate state rather than a promise that it is not.
|
|
90
|
+
const COLORS = Object.freeze(["#e8590c", "#1971c2", "#2f9e44", "#9c36b5", "#e64980", "#f08c00", "#0c8599", "#6741d9"]);
|
|
91
|
+
|
|
92
|
+
// The board mirror's KV binding, under either name.
|
|
93
|
+
//
|
|
94
|
+
// The standalone realtime worker binds it as BOARD_KV; the engine worker binds the same
|
|
95
|
+
// namespace as COMMENTS, which its own template has always documented as "the same
|
|
96
|
+
// namespace the Pages project binds as COMMENTS". Folding realtime into the engine worker
|
|
97
|
+
// therefore removes a redundant binding rather than adding one — but the standalone worker
|
|
98
|
+
// is still deployed on three instances, so this accepts both names for as long as both
|
|
99
|
+
// deployments exist. COMMENTS wins where both are present, which is the engine worker.
|
|
100
|
+
const boardKv = (env) => (env && (env.COMMENTS || env.BOARD_KV)) || null;
|
|
101
|
+
|
|
102
|
+
const PERSIST_MS = 45000; // dirty → alarm → KV mirror write; ≤ ~80 writes/hour per hot board
|
|
103
|
+
const RETRY_MS = 5000; // re-arm delay after a FAILED mirror write (KV hiccup — retry soon)
|
|
104
|
+
const MAX_TIMER_MS = 99 * 60000 + 59000; // 99:59 — a session timer, not a scheduler
|
|
105
|
+
const STALE_TIMER_MS = 3600000; // an expired timer older than this is forgotten, not shown at 00:00
|
|
106
|
+
const TOMB_TTL = 45 * 86400000; // tombstones outlive any realistic stale tab, then prune
|
|
107
|
+
const TOMB_MAX = 5000; // hard cap — a board that deleted 5k nodes can afford resurrection risk
|
|
108
|
+
const NODE_CHUNK = 1800000; // storage rows cap at 2MB; larger node JSON splits into N:<id>:<i>
|
|
109
|
+
const PUT_BATCH = 100; // storage.put(object) accepts ≤128 keys per call
|
|
110
|
+
|
|
111
|
+
const vOf = (x) => (x && typeof x.v === "number" ? x.v : 0);
|
|
112
|
+
const vnOf = (x) => (x && typeof x.vn === "number" ? x.vn : 0);
|
|
113
|
+
// deterministic total order: version, then nonce. Equal v+vn = the same write (idempotent).
|
|
114
|
+
const beats = (a, b) => vOf(a) > vOf(b) || (vOf(a) === vOf(b) && vnOf(a) > vnOf(b));
|
|
115
|
+
const sameV = (a, b) => vOf(a) === vOf(b) && vnOf(a) === vnOf(b);
|
|
116
|
+
const rnd = () => Math.floor(Math.random() * 0x7fffffff);
|
|
117
|
+
// tombs is a plain object keyed by CLIENT-CHOSEN node ids — reads must not hit inherited
|
|
118
|
+
// members (an id like "constructor" made every create bounce as tombed) and writes must
|
|
119
|
+
// not follow the "__proto__" setter. Same rule client-side.
|
|
120
|
+
const tombAt = (t, id) => (t && Object.prototype.hasOwnProperty.call(t, id) ? t[id] : null);
|
|
121
|
+
const setTomb = (t, id, val) => Object.defineProperty(t, id, { value: val, writable: true, enumerable: true, configurable: true });
|
|
122
|
+
// cheap content hash (FNV-1a) — used only to detect "did KV move under us since our last
|
|
123
|
+
// mirror write", never for integrity
|
|
124
|
+
const hashStr = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36) + ":" + s.length; };
|
|
125
|
+
|
|
126
|
+
export class BoardRoom {
|
|
127
|
+
constructor(ctx, env) {
|
|
128
|
+
this.ctx = ctx;
|
|
129
|
+
this.env = env;
|
|
130
|
+
this.doc = null; // {name,nameV,nodes:[…],tombs:{id:{v,t}},clock} — cache over storage
|
|
131
|
+
this.byId = null; // Map id → node, rebuilt with doc
|
|
132
|
+
this.chunked = new Set(); // node ids stored as N:<id>:<i> overflow chunks (JSON > 1.8MB)
|
|
133
|
+
this.loadP = null; // in-flight load, so concurrent messages share one storage read
|
|
134
|
+
this.path = null; // board path cache (durable under "path")
|
|
135
|
+
this.workspace = null; // whose board this is, or "" for none (durable under "ws"); null = not read yet
|
|
136
|
+
this.ephemeral = false; // /__test/ room: RAM only, never storage, never KV
|
|
137
|
+
this.wantDoc = false; // test rooms only: a docreq is in flight, don't spam
|
|
138
|
+
this.dirty = false; // KV mirror is behind storage (mirrored durably — see markDirty)
|
|
139
|
+
this.alarmSet = false;
|
|
140
|
+
this.sweptAt = 0;
|
|
141
|
+
this.sess = null; // shared timer/music state (cache — reloaded from storage on demand)
|
|
142
|
+
this.sessQ = null; // serializes session mutations (read-modify-write over storage)
|
|
143
|
+
// clients ping every 25s; the runtime pongs WITHOUT waking the DO and stamps the
|
|
144
|
+
// socket, so sweep() can spot zombies (dropped transports whose close never fired —
|
|
145
|
+
// sends to them "succeed" into the void, so send-failure reaping can't catch them)
|
|
146
|
+
this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- the mirror's key ------------------------------------------------------
|
|
150
|
+
// Three reads and one write name this document, and they all come through here so the
|
|
151
|
+
// read-through and the scoped write cannot be applied to three of the four.
|
|
152
|
+
|
|
153
|
+
/** This board's workspace, "" for none. Durable, so an alarm wake knows it. */
|
|
154
|
+
async workspaceId() {
|
|
155
|
+
if (this.workspace === null) this.workspace = (await this.ctx.storage.get("ws")) || "";
|
|
156
|
+
return this.workspace;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The mirror as it stands in KV, or null.
|
|
161
|
+
*
|
|
162
|
+
* READ-THROUGH: a scoped miss falls back to the unscoped key, because a board written
|
|
163
|
+
* before the segment existed is still this board. The fallback is not a merge — the
|
|
164
|
+
* scoped document, once it exists, is the only one read — and the next mirror write
|
|
165
|
+
* lands scoped, so a board pays it once. It is skipped entirely where there is no
|
|
166
|
+
* workspace, which is every deployment that has not moved its rooms in here.
|
|
167
|
+
*/
|
|
168
|
+
async kvGet() {
|
|
169
|
+
const kv = boardKv(this.env);
|
|
170
|
+
if (!kv || !this.path) return null;
|
|
171
|
+
const ws = await this.workspaceId();
|
|
172
|
+
if (ws) {
|
|
173
|
+
const scoped = await kv.get(boardKvKey(ws, this.path));
|
|
174
|
+
if (scoped !== null && scoped !== undefined) return scoped;
|
|
175
|
+
}
|
|
176
|
+
return await kv.get(boardKvKey("", this.path));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Write the mirror, under this board's own key. */
|
|
180
|
+
async kvPut(value) {
|
|
181
|
+
const kv = boardKv(this.env);
|
|
182
|
+
if (!kv || !this.path) return;
|
|
183
|
+
await kv.put(boardKvKey(await this.workspaceId(), this.path), value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sweep() {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
if (now - this.sweptAt < 10000) return;
|
|
189
|
+
this.sweptAt = now;
|
|
190
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
191
|
+
if (!this.isLive(ws)) this.reap(ws); // no ping in ~3 intervals = dead
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
peers(excludeWs) {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
198
|
+
if (ws === excludeWs) continue;
|
|
199
|
+
const a = ws.deserializeAttachment();
|
|
200
|
+
if (a) out.push({ sid: a.sid, name: a.name, color: a.color, avatar: a.avatar || null, kind: a.kind || null, pose: a.pose || null, focus: a.focus || null, sel: a.sel || null, status: a.status || null, view: a.view || null });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
broadcast(msg, exceptWs) {
|
|
206
|
+
const raw = typeof msg === "string" ? msg : JSON.stringify(msg);
|
|
207
|
+
const dead = [];
|
|
208
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
209
|
+
if (ws === exceptWs) continue;
|
|
210
|
+
try { ws.send(raw); } catch (e) { dead.push(ws); }
|
|
211
|
+
}
|
|
212
|
+
// a failed send = a zombie (an aborted handshake or vanished client whose close event
|
|
213
|
+
// never fired) — reap NOW or it haunts the peers list as a phantom presence chip
|
|
214
|
+
for (const ws of dead) this.reap(ws);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
send(ws, msg) { try { ws.send(JSON.stringify(msg)); } catch (e) {} }
|
|
218
|
+
|
|
219
|
+
async fetch(request) {
|
|
220
|
+
const url = new URL(request.url);
|
|
221
|
+
const path = (url.searchParams.get("path") || "").slice(0, 600);
|
|
222
|
+
if (path) { this.path = path; this.ephemeral = path.indexOf("/__test/") === 0; this.ctx.storage.put("path", path); }
|
|
223
|
+
// The workspace, from the worker that holds the room binding. Remembered durably for
|
|
224
|
+
// the same reason `path` is: the alarm that mirrors this board can fire long after
|
|
225
|
+
// every socket has gone, with no request to read it from.
|
|
226
|
+
const ws = (request.headers.get(RT_WORKSPACE_HEADER) || "").slice(0, 128);
|
|
227
|
+
if (ws && ws !== this.workspace) { this.workspace = ws; this.ctx.storage.put("ws", ws); }
|
|
228
|
+
// Room ticket (A-room-tickets). When the deployment folds the rooms into the engine
|
|
229
|
+
// worker it configures ROOM_TICKET_SECRET, the worker mints a ticket bound to this
|
|
230
|
+
// workspace+path after its auth gate, and we refuse the Upgrade without a valid one —
|
|
231
|
+
// recomputing the MAC from the workspace WE were handed (not one a client can name) and
|
|
232
|
+
// the path on this URL, so a ticket for another board or another workspace cannot open
|
|
233
|
+
// this one. With no secret (the standalone realtime worker, offline, a raw build) the
|
|
234
|
+
// join is accepted exactly as before: tickets exist only where the rooms are bound.
|
|
235
|
+
if (this.env && this.env.ROOM_TICKET_SECRET) {
|
|
236
|
+
const ok = path && await verifyRoomTicket(
|
|
237
|
+
this.env.ROOM_TICKET_SECRET, url.searchParams.get("ticket") || "", { workspace: ws, path });
|
|
238
|
+
if (!ok) return new Response("forbidden", { status: 403 });
|
|
239
|
+
}
|
|
240
|
+
const name = (url.searchParams.get("name") || "Guest").slice(0, 60);
|
|
241
|
+
const sid = "p" + Math.random().toString(36).slice(2, 10);
|
|
242
|
+
// `kind=agent` marks a Claude collaboration client — clients render it as Clawd, not the
|
|
243
|
+
// arrow. Only an agent may PIN its color (so Clawd stays its brand hue instead of a
|
|
244
|
+
// palette slot); humans always take the next palette color (no color hijacking).
|
|
245
|
+
const kind = (url.searchParams.get("kind") || "").slice(0, 16) || null;
|
|
246
|
+
const reqColor = url.searchParams.get("color");
|
|
247
|
+
const pinned = kind === "agent" && reqColor && /^#[0-9a-fA-F]{6}$/.test(reqColor) ? reqColor : null;
|
|
248
|
+
const color = pinned || COLORS[this.ctx.getWebSockets().length % COLORS.length];
|
|
249
|
+
// an account avatar rides the join and is relayed to peers — same-origin PATHS only,
|
|
250
|
+
// so the room never becomes a vehicle for arbitrary external images
|
|
251
|
+
const reqAvatar = (url.searchParams.get("avatar") || "").slice(0, 300);
|
|
252
|
+
const avatar = reqAvatar.startsWith("/") ? reqAvatar : null;
|
|
253
|
+
|
|
254
|
+
// the doc comes up BEFORE the welcome goes out, so every joiner starts from the
|
|
255
|
+
// room's authoritative state — never from a possibly-stale KV read of their own
|
|
256
|
+
await this.load();
|
|
257
|
+
|
|
258
|
+
this.sweep();
|
|
259
|
+
const pair = new WebSocketPair();
|
|
260
|
+
const [client, server] = Object.values(pair);
|
|
261
|
+
this.ctx.acceptWebSocket(server);
|
|
262
|
+
server.serializeAttachment({ sid, name, color, avatar, kind, pose: null, focus: null, joined: Date.now() });
|
|
263
|
+
|
|
264
|
+
const welcome = { t: "welcome", sid, color, peers: this.peers(server) };
|
|
265
|
+
if (this.doc) welcome.doc = this.wireDoc();
|
|
266
|
+
else welcome.needDoc = true; // brand-new board (or a cold test room): the client seeds
|
|
267
|
+
// a joiner walks into a running timer mid-countdown — hand them the live values
|
|
268
|
+
const sess = await this.sessionState();
|
|
269
|
+
if (sess.timer || sess.music) welcome.session = this.sessionWire(sess);
|
|
270
|
+
this.send(server, welcome);
|
|
271
|
+
this.broadcast({ t: "join", peer: { sid, name, color, avatar, kind, pose: null, focus: null } }, server);
|
|
272
|
+
|
|
273
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async webSocketMessage(ws, raw) {
|
|
277
|
+
if (typeof raw !== "string" || raw.length > MAX_MSG) return;
|
|
278
|
+
this.sweep();
|
|
279
|
+
let msg;
|
|
280
|
+
try { msg = JSON.parse(raw); } catch (e) { return; }
|
|
281
|
+
const a = ws.deserializeAttachment();
|
|
282
|
+
if (!a || !msg || !msg.t) return;
|
|
283
|
+
|
|
284
|
+
if (msg.t === "cursor") {
|
|
285
|
+
const out = { t: "cursor", sid: a.sid, name: a.name, color: a.color, kind: a.kind || null, x: msg.x, y: msg.y, gone: !!msg.gone };
|
|
286
|
+
// drag fast-path: mid-drag geometry rides the cursor cadence (relay only — never
|
|
287
|
+
// applied to the room doc; the durable upserts follow on the sender's ops tick)
|
|
288
|
+
if (Array.isArray(msg.drag) && msg.drag.length && msg.drag.length <= 64) out.drag = msg.drag;
|
|
289
|
+
this.broadcast(out, ws);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (msg.t === "sel") {
|
|
293
|
+
// live selection (colored outlines on what each person has selected) — kept on the
|
|
294
|
+
// attachment so late joiners see it via peers()
|
|
295
|
+
a.sel = Array.isArray(msg.ids) ? msg.ids.slice(0, 200).filter((x) => typeof x === "string") : null;
|
|
296
|
+
if (a.sel && !a.sel.length) a.sel = null;
|
|
297
|
+
ws.serializeAttachment(a);
|
|
298
|
+
this.broadcast({ t: "sel", sid: a.sid, color: a.color, ids: a.sel }, ws);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (msg.t === "view") {
|
|
302
|
+
// live viewport (pan/zoom + window size) — follow mode mirrors it. Kept on the
|
|
303
|
+
// attachment so clicking Follow (or joining late) syncs before the peer next moves
|
|
304
|
+
const v = msg.v;
|
|
305
|
+
a.view = v && [v.x, v.y, v.s, v.w, v.h].every((n) => typeof n === "number" && isFinite(n)) && v.s > 0
|
|
306
|
+
? { x: v.x, y: v.y, s: v.s, w: Math.max(1, v.w), h: Math.max(1, v.h) } : null;
|
|
307
|
+
ws.serializeAttachment(a);
|
|
308
|
+
this.broadcast({ t: "view", sid: a.sid, view: a.view }, ws);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (msg.t === "proto") {
|
|
312
|
+
// demo sync inside live prototype tiles — pure ephemeral relay, nothing stored
|
|
313
|
+
this.broadcast({ t: "proto", sid: a.sid, id: msg.id, ev: msg.ev }, ws);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (msg.t === "ops" && Array.isArray(msg.ops)) {
|
|
317
|
+
await this.load();
|
|
318
|
+
if (!this.doc) {
|
|
319
|
+
// only reachable in a test room (real rooms always load or start a doc): relay for
|
|
320
|
+
// liveness and ask the sender — who by definition has the state — for a snapshot
|
|
321
|
+
this.broadcast({ t: "ops", sid: a.sid, ops: msg.ops }, ws);
|
|
322
|
+
if (!this.wantDoc) { this.wantDoc = true; this.send(ws, { t: "docreq" }); }
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const r = this.applyOps(msg.ops, /*seedMode*/ false);
|
|
326
|
+
if (r.accepted.length) this.broadcast({ t: "ops", sid: a.sid, ops: r.accepted }, ws);
|
|
327
|
+
// the sender lost one or more races — hand them the winning state so they converge
|
|
328
|
+
// (everyone else already holds it or is about to via the accepted broadcast)
|
|
329
|
+
if (r.corrections.length) this.send(ws, { t: "ops", sid: "room", ops: r.corrections });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (msg.t === "focus") {
|
|
333
|
+
a.focus = msg.id || null;
|
|
334
|
+
ws.serializeAttachment(a);
|
|
335
|
+
this.broadcast({ t: "focus", sid: a.sid, name: a.name, color: a.color, id: a.focus }, ws);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (msg.t === "status") {
|
|
339
|
+
// persistent "what am I working on" line under a cursor — survives on the attachment
|
|
340
|
+
// so late joiners see it (unlike chat, which is a moment, not a state)
|
|
341
|
+
const text = (typeof msg.text === "string" ? msg.text : "").slice(0, 120);
|
|
342
|
+
const state = ["working", "idle", "attention", "done"].indexOf(msg.state) >= 0 ? msg.state : "working";
|
|
343
|
+
a.status = text || state !== "working" ? { text, state } : null;
|
|
344
|
+
ws.serializeAttachment(a);
|
|
345
|
+
this.broadcast({ t: "status", sid: a.sid, status: a.status }, ws);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (msg.t === "chat") {
|
|
349
|
+
// cursor chat: ephemeral, relayed and forgotten (no storage, no replay)
|
|
350
|
+
const text = (typeof msg.text === "string" ? msg.text : "").slice(0, 200);
|
|
351
|
+
if (text) this.broadcast({ t: "chat", sid: a.sid, name: a.name, color: a.color, kind: a.kind || null, text }, ws);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (msg.t === "timer" || msg.t === "music") {
|
|
355
|
+
// Serialized, not fire-and-forget: two people hitting "+1 min" in the same tick would
|
|
356
|
+
// otherwise both read the pre-write state and the second would overwrite the first's
|
|
357
|
+
// minute instead of stacking on it.
|
|
358
|
+
this.sessQ = (this.sessQ || Promise.resolve()).then(() => this.applySession(msg)).catch(() => {});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (msg.t === "kick") {
|
|
362
|
+
// Remove an AGENT from the board. Delivered only to the target, whose client ends its
|
|
363
|
+
// own process on receipt — a real eviction, not a UI one, which is the only kind worth
|
|
364
|
+
// having (hiding the avatar would leave the thing still editing the board).
|
|
365
|
+
// AGENTS ONLY: a human's tab belongs to that human, not to whoever else is in the room.
|
|
366
|
+
// Pure relay, nothing stored — a room that remembered its evictions would need an
|
|
367
|
+
// un-ban path and a policy about who may set one. The record that it happened belongs
|
|
368
|
+
// with the agent instead, in its own event log, where its next turn will read it.
|
|
369
|
+
const target = typeof msg.sid === "string" ? msg.sid : "";
|
|
370
|
+
if (!target) return;
|
|
371
|
+
for (const peer of this.ctx.getWebSockets()) {
|
|
372
|
+
const p = peer.deserializeAttachment();
|
|
373
|
+
if (!p || p.sid !== target || p.kind !== "agent") continue;
|
|
374
|
+
this.send(peer, { t: "kick", sid: target, by: a.name || "" });
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (msg.t === "pose") {
|
|
379
|
+
// an agent's Clawd expression (idle/coding/sleeping/…) — stored per-session so late
|
|
380
|
+
// joiners see the right face, and relayed so everyone updates it live.
|
|
381
|
+
a.pose = (typeof msg.pose === "string" ? msg.pose : "").slice(0, 24) || null;
|
|
382
|
+
ws.serializeAttachment(a);
|
|
383
|
+
this.broadcast({ t: "pose", sid: a.sid, pose: a.pose }, ws);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (msg.t === "doc" && msg.doc && Array.isArray(msg.doc.nodes)) {
|
|
387
|
+
await this.load();
|
|
388
|
+
this.wantDoc = false;
|
|
389
|
+
if (!this.doc) {
|
|
390
|
+
// brand-new board (or a test room rebuilding after hibernation): adopt wholesale —
|
|
391
|
+
// there is nothing to reconcile against. Nodes keep the versions they came with
|
|
392
|
+
// (v-less stays v-less = v0), so the seeder's own later bumps still win cleanly.
|
|
393
|
+
this.adoptDoc(msg.doc);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// A board exists → the seed is an OFFER, reconciled per-node under the version rules
|
|
397
|
+
// (v-less seed nodes count as v0). A slept-laptop tab reconnecting with last week's
|
|
398
|
+
// RAM merges its genuinely-new nodes and LOSES everything the board has since
|
|
399
|
+
// out-versioned — the exact opposite of the old wholesale adopt, which let the
|
|
400
|
+
// stalest client in the world overwrite everyone (the #1 loss bug of the audit).
|
|
401
|
+
const r = this.reconcileSeed(msg.doc);
|
|
402
|
+
if (r.accepted.length) this.broadcast({ t: "ops", sid: "room", ops: r.accepted }, ws);
|
|
403
|
+
// where the room won, the seeder is the one holding stale state — correct them
|
|
404
|
+
if (r.corrections.length) this.send(ws, { t: "ops", sid: "room", ops: r.corrections });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- session: the shared timer + music ------------------------------------
|
|
410
|
+
// Room-level state, unlike status/pose which hang off one socket: a room has ONE timer and
|
|
411
|
+
// ONE track, the same for everyone. Held in ctx.storage (not the doc) because a countdown
|
|
412
|
+
// is a moment, not board content — it must not land in the KV document, ride the ops tick,
|
|
413
|
+
// or turn up in undo.
|
|
414
|
+
//
|
|
415
|
+
// NO ALARM, deliberately. Expiry is computed by each client from the remaining ms below.
|
|
416
|
+
// This DO has a single alarm slot and it belongs to the KV persist rail; a timer that
|
|
417
|
+
// borrowed it would silently cancel a pending document write — losing board edits to
|
|
418
|
+
// show a countdown would be a terrible trade.
|
|
419
|
+
//
|
|
420
|
+
// Time goes on the wire as REMAINING MILLISECONDS AT SEND, never as an absolute deadline.
|
|
421
|
+
// A client stamps arrival with its own monotonic clock and counts down from there, so a
|
|
422
|
+
// laptop whose wall clock is ten minutes off still shows the same 04:56 as everyone else.
|
|
423
|
+
// The stored `at` is only ever differenced against this DO's own clock, so it stays exact
|
|
424
|
+
// across hibernation.
|
|
425
|
+
async sessionState() {
|
|
426
|
+
if (!this.sess) {
|
|
427
|
+
const s = (await this.ctx.storage.get("sess")) || {};
|
|
428
|
+
this.sess = { timer: s.timer || null, music: s.music || null };
|
|
429
|
+
// don't greet a joiner with last month's meeting frozen at 00:00
|
|
430
|
+
const t = this.sess.timer;
|
|
431
|
+
if (t && !t.running && t.remain <= 0 && Date.now() - t.at > STALE_TIMER_MS) this.sess.timer = null;
|
|
432
|
+
}
|
|
433
|
+
return this.sess;
|
|
434
|
+
}
|
|
435
|
+
// stored values rolled forward to this instant
|
|
436
|
+
sessionWire(s) {
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
const out = { timer: null, music: null };
|
|
439
|
+
if (s.timer) {
|
|
440
|
+
const remain = s.timer.running ? Math.max(0, s.timer.remain - (now - s.timer.at)) : s.timer.remain;
|
|
441
|
+
// An EXPIRED countdown is not state: clients revert to idle the moment they hit 00:00
|
|
442
|
+
// (they announce it locally — the room broadcasts nothing at that instant), so a joiner
|
|
443
|
+
// must not be handed a frozen 00:00 the room they're joining stopped showing.
|
|
444
|
+
if (remain > 0) out.timer = { running: !!s.timer.running, remain, total: s.timer.total };
|
|
445
|
+
}
|
|
446
|
+
if (s.music) {
|
|
447
|
+
out.music = {
|
|
448
|
+
track: s.music.track,
|
|
449
|
+
playing: !!s.music.playing,
|
|
450
|
+
elapsed: s.music.playing ? s.music.elapsed + (now - s.music.at) : s.music.elapsed,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
saveSession(s) {
|
|
456
|
+
this.sess = s;
|
|
457
|
+
this.ctx.storage.put("sess", s);
|
|
458
|
+
// to EVERYONE including the sender: the room's value is the authoritative one, so the
|
|
459
|
+
// person who clicked snaps to it too rather than trusting their own optimistic guess
|
|
460
|
+
this.broadcast({ t: "session", ...this.sessionWire(s) });
|
|
461
|
+
}
|
|
462
|
+
async applySession(msg) {
|
|
463
|
+
const s = await this.sessionState();
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
if (msg.t === "timer") {
|
|
466
|
+
const cur = s.timer;
|
|
467
|
+
const remain = cur ? (cur.running ? Math.max(0, cur.remain - (now - cur.at)) : cur.remain) : 0;
|
|
468
|
+
const arg = Math.min(Math.max(Math.round(Number(msg.ms)) || 0, 0), MAX_TIMER_MS);
|
|
469
|
+
if (msg.do === "start") {
|
|
470
|
+
if (arg < 1000) return;
|
|
471
|
+
s.timer = { running: true, remain: arg, total: arg, at: now };
|
|
472
|
+
} else if (msg.do === "add") {
|
|
473
|
+
if (!cur || !arg) return;
|
|
474
|
+
const next = Math.min(remain + arg, MAX_TIMER_MS);
|
|
475
|
+
// adding time to a timer that already rang restarts it — that IS the point of
|
|
476
|
+
// "+1 min" at 00:00, and it's the only way back without re-entering the duration
|
|
477
|
+
s.timer = { running: cur.running || remain <= 0, remain: next, total: Math.max(cur.total, next), at: now };
|
|
478
|
+
} else if (msg.do === "pause") {
|
|
479
|
+
if (!cur || !cur.running || remain <= 0) return;
|
|
480
|
+
s.timer = { running: false, remain, total: cur.total, at: now };
|
|
481
|
+
} else if (msg.do === "resume") {
|
|
482
|
+
if (!cur || cur.running || remain <= 0) return;
|
|
483
|
+
s.timer = { running: true, remain, total: cur.total, at: now };
|
|
484
|
+
} else if (msg.do === "stop") {
|
|
485
|
+
if (!cur) return;
|
|
486
|
+
s.timer = null;
|
|
487
|
+
} else return;
|
|
488
|
+
} else {
|
|
489
|
+
const cur = s.music;
|
|
490
|
+
if (msg.do === "play") {
|
|
491
|
+
const track = (typeof msg.track === "string" ? msg.track : "").slice(0, 64);
|
|
492
|
+
if (!track) return;
|
|
493
|
+
// Resuming the same track picks up where it stopped; a NEW track takes the offset the
|
|
494
|
+
// caller chose. That offset is a random entry point, which is why it's the client's to
|
|
495
|
+
// pick and the room's to make authoritative — everyone must land on the same bar.
|
|
496
|
+
const same = cur && cur.track === track;
|
|
497
|
+
const elapsed = same
|
|
498
|
+
? (cur.playing ? cur.elapsed + (now - cur.at) : cur.elapsed)
|
|
499
|
+
: Math.max(0, Math.round(Number(msg.at)) || 0);
|
|
500
|
+
s.music = { track, playing: true, elapsed, at: now };
|
|
501
|
+
} else if (msg.do === "stop") {
|
|
502
|
+
if (!cur || !cur.playing) return;
|
|
503
|
+
s.music = { track: cur.track, playing: false, elapsed: cur.elapsed + (now - cur.at), at: now };
|
|
504
|
+
} else return;
|
|
505
|
+
}
|
|
506
|
+
this.saveSession(s);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---- the document (storage-backed, version-ruled) --------------------------
|
|
510
|
+
wireDoc() {
|
|
511
|
+
// tombs ride along so a reconnecting client can apply deletions it slept through
|
|
512
|
+
return { name: this.doc.name, nameV: this.doc.nameV, nodes: this.doc.nodes, tombs: this.doc.tombs, clock: this.doc.clock };
|
|
513
|
+
}
|
|
514
|
+
indexDoc() {
|
|
515
|
+
this.byId = new Map();
|
|
516
|
+
for (const n of this.doc.nodes) this.byId.set(n.id, n);
|
|
517
|
+
}
|
|
518
|
+
async load() {
|
|
519
|
+
if (this.doc || this.ephemeral) return;
|
|
520
|
+
if (!this.loadP) this.loadP = this._load().finally(() => { this.loadP = null; });
|
|
521
|
+
return this.loadP;
|
|
522
|
+
}
|
|
523
|
+
async _load() {
|
|
524
|
+
if (!this.path) {
|
|
525
|
+
this.path = await this.ctx.storage.get("path");
|
|
526
|
+
this.ephemeral = !!this.path && this.path.indexOf("/__test/") === 0;
|
|
527
|
+
if (this.ephemeral) return;
|
|
528
|
+
}
|
|
529
|
+
const m = await this.ctx.storage.get("m");
|
|
530
|
+
if (m) {
|
|
531
|
+
const rows = await this.ctx.storage.list({ prefix: "n:" });
|
|
532
|
+
const nodes = [];
|
|
533
|
+
this.chunked = new Set();
|
|
534
|
+
for (const [key, val] of rows) {
|
|
535
|
+
let node = typeof val === "string" ? JSON.parse(val) : val;
|
|
536
|
+
if (node && node.__c) { // oversize node, stored chunked
|
|
537
|
+
const parts = [];
|
|
538
|
+
for (let i = 0; i < node.__c; i++) parts.push(await this.ctx.storage.get("N:" + key.slice(2) + ":" + i));
|
|
539
|
+
node = JSON.parse(parts.join(""));
|
|
540
|
+
if (node) this.chunked.add(node.id);
|
|
541
|
+
}
|
|
542
|
+
if (node && node.id) nodes.push(node);
|
|
543
|
+
}
|
|
544
|
+
// restore the persisted z-order (rows come back key-sorted); ids the order row
|
|
545
|
+
// doesn't know (mid-flight writes) sink to the end, stably
|
|
546
|
+
if (Array.isArray(m.order)) {
|
|
547
|
+
const pos = new Map(m.order.map((id, i) => [id, i]));
|
|
548
|
+
nodes.sort((a, b) => (pos.has(a.id) ? pos.get(a.id) : Infinity) - (pos.has(b.id) ? pos.get(b.id) : Infinity));
|
|
549
|
+
}
|
|
550
|
+
this.doc = { name: m.name || "Untitled canvas", nameV: m.nameV || 0, nodes, tombs: m.tombs || {}, clock: m.clock || 0 };
|
|
551
|
+
this.indexDoc();
|
|
552
|
+
// Fold the KV mirror back in (version-ruled, so a lagging mirror merges to nothing):
|
|
553
|
+
// solo clients and terminal scripts legitimately write /__board while the room is
|
|
554
|
+
// empty, and waking up storage-only would erase their work at the next mirror write.
|
|
555
|
+
try {
|
|
556
|
+
const raw = await this.kvGet();
|
|
557
|
+
const kv = raw ? JSON.parse(raw) : null;
|
|
558
|
+
if (kv && Array.isArray(kv.nodes)) this.reconcileSeed(kv); // no sender — corrections go nowhere, accepted ops are already durable
|
|
559
|
+
} catch (e) {}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await this.migrate();
|
|
563
|
+
}
|
|
564
|
+
// One-time, per board: bring a pre-storage board into the DO. Sources, in trust order:
|
|
565
|
+
// the legacy in-storage stash IF it was dirty (a write the old code owed KV and may never
|
|
566
|
+
// have delivered), else the KV doc. When both exist they reconcile per-node — everything
|
|
567
|
+
// is v-less here, so ties go to whichever source the dirty flag says was newer.
|
|
568
|
+
async migrate() {
|
|
569
|
+
const stash = await this.ctx.storage.get("doc");
|
|
570
|
+
const wasDirty = !!(await this.ctx.storage.get("dirty"));
|
|
571
|
+
let kvDoc = null;
|
|
572
|
+
try {
|
|
573
|
+
const raw = await this.kvGet();
|
|
574
|
+
kvDoc = raw ? JSON.parse(raw) : null;
|
|
575
|
+
} catch (e) {}
|
|
576
|
+
let src = null;
|
|
577
|
+
if (kvDoc && stash && Array.isArray(kvDoc.nodes) && Array.isArray(stash.nodes)) {
|
|
578
|
+
const base = wasDirty ? stash : kvDoc, over = wasDirty ? kvDoc : stash;
|
|
579
|
+
const ids = new Map(base.nodes.map((n) => [n.id, n]));
|
|
580
|
+
for (const n of over.nodes) if (n && n.id && !ids.has(n.id)) ids.set(n.id, n); // union — deletions can't be told apart here, keep both
|
|
581
|
+
src = { name: base.name, nodes: [...ids.values()] };
|
|
582
|
+
} else src = kvDoc || stash || null;
|
|
583
|
+
if (!src || !Array.isArray(src.nodes)) return; // brand-new board — stays null until a client seeds
|
|
584
|
+
this.doc = {
|
|
585
|
+
name: src.name || "Untitled canvas",
|
|
586
|
+
nameV: src.nameV || 0,
|
|
587
|
+
nodes: src.nodes.filter((n) => n && n.id),
|
|
588
|
+
tombs: src.tombs || {},
|
|
589
|
+
clock: src.clock || 0,
|
|
590
|
+
};
|
|
591
|
+
this.indexDoc();
|
|
592
|
+
this.writeAll();
|
|
593
|
+
this.ctx.storage.delete("doc"); // the legacy stash's job is done
|
|
594
|
+
if (wasDirty) this.markDirty(); // the old code owed KV a write — we inherit the debt
|
|
595
|
+
}
|
|
596
|
+
putNode(puts, dels, n) {
|
|
597
|
+
const s = JSON.stringify(n);
|
|
598
|
+
if (s.length <= NODE_CHUNK) {
|
|
599
|
+
puts["n:" + n.id] = s;
|
|
600
|
+
if (this.chunked.delete(n.id)) dels.push(...this.chunkKeys(n.id)); // shrank back under the limit
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const k = Math.ceil(s.length / NODE_CHUNK);
|
|
604
|
+
for (let i = 0; i < k; i++) puts["N:" + n.id + ":" + i] = s.slice(i * NODE_CHUNK, (i + 1) * NODE_CHUNK);
|
|
605
|
+
puts["n:" + n.id] = JSON.stringify({ __c: k });
|
|
606
|
+
this.chunked.add(n.id);
|
|
607
|
+
}
|
|
608
|
+
chunkKeys(id) {
|
|
609
|
+
// over-delete up to the practical ceiling (16 × 1.8MB ≫ the 20MB doc cap); deleting a
|
|
610
|
+
// missing key is a no-op, and this only runs when a node crosses the chunk boundary
|
|
611
|
+
const out = [];
|
|
612
|
+
for (let i = 0; i < 16; i++) out.push("N:" + id + ":" + i);
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
metaRow() {
|
|
616
|
+
// `order` pins node z-order across cold loads — storage.list returns rows in KEY
|
|
617
|
+
// order, which silently re-stacked overlapping nodes after every hibernation
|
|
618
|
+
return { name: this.doc.name, nameV: this.doc.nameV, tombs: this.doc.tombs, clock: this.doc.clock, order: this.doc.nodes.map((n) => n.id) };
|
|
619
|
+
}
|
|
620
|
+
writeAll() {
|
|
621
|
+
const puts = { m: this.metaRow() }, dels = [];
|
|
622
|
+
for (const n of this.doc.nodes) this.putNode(puts, dels, n);
|
|
623
|
+
this.flushRows(puts, dels);
|
|
624
|
+
}
|
|
625
|
+
flushRows(puts, dels) {
|
|
626
|
+
if (this.ephemeral) return;
|
|
627
|
+
// no awaits between these — the runtime coalesces them into one atomic write batch
|
|
628
|
+
const keys = Object.keys(puts);
|
|
629
|
+
for (let i = 0; i < keys.length; i += PUT_BATCH) {
|
|
630
|
+
const slice = {};
|
|
631
|
+
for (const k of keys.slice(i, i + PUT_BATCH)) slice[k] = puts[k];
|
|
632
|
+
this.ctx.storage.put(slice).catch((e) => console.error("storage put failed", e));
|
|
633
|
+
}
|
|
634
|
+
for (let i = 0; i < dels.length; i += PUT_BATCH)
|
|
635
|
+
this.ctx.storage.delete(dels.slice(i, i + PUT_BATCH)).catch(() => {});
|
|
636
|
+
}
|
|
637
|
+
adoptDoc(d) {
|
|
638
|
+
this.doc = {
|
|
639
|
+
name: typeof d.name === "string" ? d.name.slice(0, 200) : "Untitled canvas",
|
|
640
|
+
nameV: vOf({ v: d.nameV }),
|
|
641
|
+
nodes: d.nodes.filter((n) => n && n.id),
|
|
642
|
+
tombs: d.tombs && typeof d.tombs === "object" ? d.tombs : {},
|
|
643
|
+
clock: 0,
|
|
644
|
+
};
|
|
645
|
+
this.indexDoc();
|
|
646
|
+
this.writeAll();
|
|
647
|
+
this.markDirty();
|
|
648
|
+
}
|
|
649
|
+
pruneTombs() {
|
|
650
|
+
const t = this.doc.tombs, now = Date.now(), ids = Object.keys(t);
|
|
651
|
+
if (ids.length <= TOMB_MAX && !ids.some((id) => now - (t[id].t || 0) > TOMB_TTL)) return;
|
|
652
|
+
const keep = ids.filter((id) => now - (t[id].t || 0) <= TOMB_TTL)
|
|
653
|
+
.sort((a, b) => (t[b].t || 0) - (t[a].t || 0)).slice(0, TOMB_MAX);
|
|
654
|
+
this.doc.tombs = {};
|
|
655
|
+
for (const id of keep) this.doc.tombs[id] = t[id];
|
|
656
|
+
}
|
|
657
|
+
// Apply a batch under the version rules. Returns what to broadcast (accepted, with
|
|
658
|
+
// room-stamped versions where the sender had none) and what to bounce back to the
|
|
659
|
+
// sender (corrections — the winning state for every op that lost).
|
|
660
|
+
// seedMode: nodes from a {t:"doc"} offer — v-less means v0 (stale until proven fresh),
|
|
661
|
+
// whereas a v-less LIVE op is a legacy client's real edit and is accepted + stamped.
|
|
662
|
+
applyOps(ops, seedMode) {
|
|
663
|
+
const accepted = [], corrections = [];
|
|
664
|
+
let metaDirty = false;
|
|
665
|
+
const puts = {}, dels = [];
|
|
666
|
+
for (const op of ops) {
|
|
667
|
+
if (!op) continue;
|
|
668
|
+
if (op.op === "upsert" && op.node && op.node.id) {
|
|
669
|
+
const n = op.node, id = n.id, cur = this.byId.get(id), tomb = tombAt(this.doc.tombs, id);
|
|
670
|
+
const versioned = typeof n.v === "number";
|
|
671
|
+
if (tomb && !(vOf(n) > tomb.v)) {
|
|
672
|
+
// deleted, and this write predates the delete — a stale resurrection attempt
|
|
673
|
+
if (!seedMode && !versioned) { /* legacy live edit of a deleted node: let the delete win */ }
|
|
674
|
+
// CORRECTIONS MUST STRICTLY OUT-VERSION THE LOSER (audit v2): on a tie the
|
|
675
|
+
// sender's own copy sits at the tomb's v, and its resurrection guard drops a
|
|
676
|
+
// del that doesn't exceed it — raise the tomb so the correction wins there too
|
|
677
|
+
if (versioned && vOf(n) === tomb.v) { tomb.v = vOf(n) + 1; setTomb(this.doc.tombs, id, tomb); metaDirty = true; }
|
|
678
|
+
corrections.push({ op: "del", id, v: tomb.v });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (cur) {
|
|
682
|
+
if (!versioned && !seedMode) {
|
|
683
|
+
// legacy client's live edit: accept and stamp, so versioned clients converge
|
|
684
|
+
n.v = vOf(cur) + 1; n.vn = rnd();
|
|
685
|
+
} else if (sameV(n, cur)) {
|
|
686
|
+
// same version twice is an idempotent echo — UNLESS the content drifted, which
|
|
687
|
+
// only unversioned (legacy/migrated) writes can produce. The room's copy wins,
|
|
688
|
+
// but a correction at the SAME v/vn would be dropped by the sender's own LWW
|
|
689
|
+
// gate — bump the winner so the correction strictly wins everywhere.
|
|
690
|
+
if (JSON.stringify(n) !== JSON.stringify(cur)) {
|
|
691
|
+
cur.v = vOf(cur) + 1; cur.vn = rnd();
|
|
692
|
+
this.putNode(puts, dels, cur);
|
|
693
|
+
accepted.push({ op: "upsert", node: cur });
|
|
694
|
+
corrections.push({ op: "upsert", node: cur });
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
697
|
+
} else if (!beats(n, cur)) {
|
|
698
|
+
// a v tie with a lower vn still loses cleanly (the correction's higher vn
|
|
699
|
+
// beats the sender's copy) — only exact sameV needed the bump above
|
|
700
|
+
corrections.push({ op: "upsert", node: cur });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
} else if (!versioned && !seedMode) {
|
|
704
|
+
n.v = tomb ? tomb.v + 1 : 1; n.vn = rnd();
|
|
705
|
+
}
|
|
706
|
+
if (tomb) { delete this.doc.tombs[id]; metaDirty = true; }
|
|
707
|
+
if (cur) this.doc.nodes[this.doc.nodes.indexOf(cur)] = n;
|
|
708
|
+
else this.doc.nodes.push(n);
|
|
709
|
+
this.byId.set(id, n);
|
|
710
|
+
this.putNode(puts, dels, n);
|
|
711
|
+
accepted.push({ op: "upsert", node: n });
|
|
712
|
+
} else if (op.op === "del" && op.id) {
|
|
713
|
+
const id = op.id, cur = this.byId.get(id), tomb = tombAt(this.doc.tombs, id);
|
|
714
|
+
const versioned = typeof op.v === "number";
|
|
715
|
+
let tombV;
|
|
716
|
+
if (cur) {
|
|
717
|
+
if (versioned && !(op.v > vOf(cur))) {
|
|
718
|
+
// the delete lost. On a TIE (concurrent delete + edit landing on the same v)
|
|
719
|
+
// the deleter's tomb sits at op.v == cur.v and its resurrection guard drops a
|
|
720
|
+
// corrective upsert that doesn't strictly exceed it — bump the survivor so the
|
|
721
|
+
// correction wins at the deleter too (audit v2: the deleter went blind forever)
|
|
722
|
+
if (op.v === vOf(cur)) {
|
|
723
|
+
cur.v = vOf(cur) + 1; cur.vn = rnd();
|
|
724
|
+
this.putNode(puts, dels, cur);
|
|
725
|
+
accepted.push({ op: "upsert", node: cur });
|
|
726
|
+
}
|
|
727
|
+
corrections.push({ op: "upsert", node: cur });
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
tombV = versioned ? op.v : vOf(cur) + 1;
|
|
731
|
+
this.doc.nodes.splice(this.doc.nodes.indexOf(cur), 1);
|
|
732
|
+
this.byId.delete(id);
|
|
733
|
+
dels.push("n:" + id);
|
|
734
|
+
if (this.chunked.delete(id)) dels.push(...this.chunkKeys(id));
|
|
735
|
+
} else {
|
|
736
|
+
// nothing to delete, but record/raise the tombstone anyway: it's what stops the
|
|
737
|
+
// node coming back when a slower peer's upsert for it arrives after this del
|
|
738
|
+
tombV = Math.max(versioned ? op.v : 1, tomb ? tomb.v + (versioned ? 0 : 1) : 0);
|
|
739
|
+
if (tomb && tombV <= tomb.v) continue;
|
|
740
|
+
}
|
|
741
|
+
setTomb(this.doc.tombs, id, { v: tombV, t: Date.now() });
|
|
742
|
+
metaDirty = true;
|
|
743
|
+
accepted.push({ op: "del", id, v: tombV });
|
|
744
|
+
} else if (op.op === "name" && typeof op.name === "string") {
|
|
745
|
+
const versioned = typeof op.v === "number";
|
|
746
|
+
const curV = this.doc.nameV;
|
|
747
|
+
if (versioned && op.v <= curV) {
|
|
748
|
+
// name has no vn tiebreak, so a TIE (both renamed offline to the same nameV) is
|
|
749
|
+
// unresolvable by the correction alone — the loser's client drops v <= its own.
|
|
750
|
+
// Bump the winning name past the tie so everyone, loser included, converges.
|
|
751
|
+
if (op.v === curV && op.name !== this.doc.name) {
|
|
752
|
+
this.doc.nameV = curV + 1;
|
|
753
|
+
metaDirty = true;
|
|
754
|
+
accepted.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
|
|
755
|
+
}
|
|
756
|
+
corrections.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
this.doc.name = op.name.slice(0, 200);
|
|
760
|
+
this.doc.nameV = versioned ? op.v : curV + 1;
|
|
761
|
+
metaDirty = true;
|
|
762
|
+
accepted.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (accepted.length || metaDirty) {
|
|
766
|
+
this.doc.clock++;
|
|
767
|
+
this.pruneTombs();
|
|
768
|
+
puts.m = this.metaRow();
|
|
769
|
+
this.flushRows(puts, dels);
|
|
770
|
+
this.markDirty();
|
|
771
|
+
}
|
|
772
|
+
return { accepted, corrections };
|
|
773
|
+
}
|
|
774
|
+
// A {t:"doc"} offer against an existing board: every node becomes a seed-mode upsert,
|
|
775
|
+
// every tomb a del; nodes the room has that the offer lacks are LEFT ALONE (an absence
|
|
776
|
+
// in a stale snapshot is not a deletion — real deletions travel as tombs/ops).
|
|
777
|
+
reconcileSeed(d) {
|
|
778
|
+
const ops = [];
|
|
779
|
+
for (const n of d.nodes) if (n && n.id) ops.push({ op: "upsert", node: n });
|
|
780
|
+
if (d.tombs && typeof d.tombs === "object")
|
|
781
|
+
for (const id of Object.keys(d.tombs)) ops.push({ op: "del", id, v: vOf({ v: d.tombs[id] && d.tombs[id].v }) });
|
|
782
|
+
if (typeof d.name === "string" && typeof d.nameV === "number") ops.push({ op: "name", name: d.name, v: d.nameV });
|
|
783
|
+
const r = this.applyOps(ops, /*seedMode*/ true);
|
|
784
|
+
// corrections the seeder needs beyond op losses: the nodes it doesn't know exist
|
|
785
|
+
const known = new Set(d.nodes.map((n) => n && n.id));
|
|
786
|
+
for (const n of this.doc.nodes) if (!known.has(n.id)) r.corrections.push({ op: "upsert", node: n });
|
|
787
|
+
return r;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- KV mirror (public GET + solo fallback read from it) -------------------
|
|
791
|
+
// Storage is the source of truth; KV gets a write-through copy on the old cadence: a
|
|
792
|
+
// dirty flag arms a 45s alarm, the last socket leaving flushes immediately. `dirty` is
|
|
793
|
+
// durable because the alarm outlives the instance that armed it (hibernation).
|
|
794
|
+
markDirty() {
|
|
795
|
+
if (this.ephemeral) return;
|
|
796
|
+
if (!this.dirty) { this.dirty = true; this.ctx.storage.put("dirty", 1); }
|
|
797
|
+
if (this.alarmSet) return;
|
|
798
|
+
this.alarmSet = true;
|
|
799
|
+
this.ctx.storage.setAlarm(Date.now() + PERSIST_MS);
|
|
800
|
+
}
|
|
801
|
+
async isDirty() {
|
|
802
|
+
if (this.dirty) return true;
|
|
803
|
+
this.dirty = !!(await this.ctx.storage.get("dirty"));
|
|
804
|
+
return this.dirty;
|
|
805
|
+
}
|
|
806
|
+
clearDirty() {
|
|
807
|
+
this.dirty = false;
|
|
808
|
+
return this.ctx.storage.delete("dirty");
|
|
809
|
+
}
|
|
810
|
+
async alarm() {
|
|
811
|
+
this.alarmSet = false;
|
|
812
|
+
if (this.ephemeral) return;
|
|
813
|
+
if (!(await this.isDirty())) return;
|
|
814
|
+
await this.load(); // hibernation wake: the doc is in storage, no client required
|
|
815
|
+
await this.mirror();
|
|
816
|
+
// still dirty = the write failed, or ops landed while it ran — either way, retry
|
|
817
|
+
if (this.dirty && !this.alarmSet) { this.alarmSet = true; this.ctx.storage.setAlarm(Date.now() + RETRY_MS); }
|
|
818
|
+
}
|
|
819
|
+
async mirror() {
|
|
820
|
+
if (!this.doc || !boardKv(this.env) || this.ephemeral) return;
|
|
821
|
+
if (!(await this.isDirty())) return;
|
|
822
|
+
await this.clearDirty(); // before the await: ops during the write re-set it
|
|
823
|
+
try {
|
|
824
|
+
// FOLD BEFORE OVERWRITE (audit v2): a solo client or terminal script may have
|
|
825
|
+
// written /__board while this room was HOT — the old blind put steamrolled that
|
|
826
|
+
// write and, since the room only reads KV on a COLD load, the edit was lost
|
|
827
|
+
// permanently (close-the-laptop-after-a-blip). If KV moved since OUR last mirror,
|
|
828
|
+
// reconcile it in (version-ruled, so a lagging copy merges to nothing) and let the
|
|
829
|
+
// live clients hear whatever was genuinely new.
|
|
830
|
+
const kvRaw = await this.kvGet();
|
|
831
|
+
if (kvRaw && hashStr(kvRaw) !== (await this.ctx.storage.get("mhash"))) {
|
|
832
|
+
try {
|
|
833
|
+
const kv = JSON.parse(kvRaw);
|
|
834
|
+
if (kv && Array.isArray(kv.nodes)) {
|
|
835
|
+
const r = this.reconcileSeed(kv);
|
|
836
|
+
if (r.accepted.length) this.broadcast({ t: "ops", sid: "room", ops: r.accepted });
|
|
837
|
+
}
|
|
838
|
+
} catch (e2) {}
|
|
839
|
+
}
|
|
840
|
+
const out = JSON.stringify(this.wireDoc());
|
|
841
|
+
await this.kvPut(out);
|
|
842
|
+
this.ctx.storage.put("mhash", hashStr(out));
|
|
843
|
+
} catch (e) {
|
|
844
|
+
console.error("KV mirror write failed", e);
|
|
845
|
+
this.dirty = true; this.ctx.storage.put("dirty", 1);
|
|
846
|
+
// a failed write deserves the FAST retry — the pending 45s cadence alarm would
|
|
847
|
+
// otherwise swallow the RETRY_MS re-arm (alarmSet is still true from markDirty)
|
|
848
|
+
this.alarmSet = true;
|
|
849
|
+
this.ctx.storage.setAlarm(Date.now() + RETRY_MS);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
webSocketClose(ws) { this.reap(ws); }
|
|
854
|
+
webSocketError(ws) { this.reap(ws); }
|
|
855
|
+
// A socket counts as LIVE if it pinged inside ~3 keepalive intervals (sweep's rule). A
|
|
856
|
+
// zombie (dead transport whose close never fired) must not block the last-one-out flush.
|
|
857
|
+
isLive(ws) {
|
|
858
|
+
const ts = this.ctx.getWebSocketAutoResponseTimestamp(ws);
|
|
859
|
+
const a = ws.deserializeAttachment();
|
|
860
|
+
const seen = ts ? ts.getTime() : (a && a.joined) || 0;
|
|
861
|
+
return !seen || Date.now() - seen <= 75000;
|
|
862
|
+
}
|
|
863
|
+
reap(ws) {
|
|
864
|
+
const a = ws.deserializeAttachment();
|
|
865
|
+
try { ws.close(); } catch (e) {}
|
|
866
|
+
if (a) this.broadcast({ t: "leave", peer: { sid: a.sid, name: a.name, color: a.color } }, ws);
|
|
867
|
+
if (!this.ctx.getWebSockets().some((s) => s !== ws && this.isLive(s))) {
|
|
868
|
+
// last one out: mirror to KV NOW (don't wait for the alarm), then drop the RAM cache
|
|
869
|
+
// — storage keeps the doc, so an empty room costs nothing and forgets nothing. A
|
|
870
|
+
// failed mirror stays dirty and the alarm retry path picks it up (the DO wakes on
|
|
871
|
+
// alarms with zero sockets — that's what alarms are for).
|
|
872
|
+
this.load()
|
|
873
|
+
.then(() => this.mirror())
|
|
874
|
+
.catch(() => {})
|
|
875
|
+
.then(() => {
|
|
876
|
+
if (this.dirty && !this.alarmSet) { this.alarmSet = true; this.ctx.storage.setAlarm(Date.now() + RETRY_MS); }
|
|
877
|
+
this.doc = null; this.byId = null;
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|