your-attention 0.1.0 → 0.1.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/README.md +2 -0
- package/bin/your-attention.js +6 -2
- package/dist/src/adapters/files.js +66 -0
- package/dist/src/adapters/mdns.js +53 -0
- package/dist/src/adapters/mermaid.js +50 -0
- package/dist/src/adapters/openui-body.js +40 -0
- package/dist/src/adapters/openui-library.js +3 -0
- package/dist/src/adapters/sqlite.js +68 -0
- package/dist/src/cli.js +814 -0
- package/dist/src/client.js +459 -0
- package/dist/src/cloudflare/collector.js +53 -0
- package/dist/src/cloudflare/inbox-object.js +338 -0
- package/dist/src/cloudflare/storage.js +151 -0
- package/dist/src/cloudflare/worker.js +189 -0
- package/dist/src/core/crypto.js +23 -0
- package/dist/src/core/diagrams.js +225 -0
- package/dist/src/core/expr.js +125 -0
- package/dist/src/core/file-links.js +48 -0
- package/dist/src/core/hooks.js +74 -0
- package/dist/src/core/http.js +651 -0
- package/dist/src/core/inbox.js +2463 -0
- package/dist/src/core/pin.js +234 -0
- package/dist/src/core/sealed.js +167 -0
- package/dist/src/core/session-link.js +13 -0
- package/dist/src/core/socket.js +88 -0
- package/dist/src/core/storage.js +102 -0
- package/dist/src/core/summary.js +149 -0
- package/dist/src/core/telemetry.js +221 -0
- package/dist/src/core/types.js +470 -0
- package/dist/src/core/version.js +2 -0
- package/dist/src/core/widgets.js +128 -0
- package/dist/src/extensions/triage.js +232 -0
- package/dist/src/integrations/claude/ask-hook.js +84 -0
- package/dist/src/integrations/claude/channel-flag.js +102 -0
- package/dist/src/integrations/claude/channel.js +276 -0
- package/dist/src/integrations/claude/session-name.js +32 -0
- package/dist/src/integrations/claude/turn-end-hook.js +84 -0
- package/dist/src/integrations/herdr/approve.js +71 -0
- package/dist/src/integrations/herdr/blocked.js +210 -0
- package/dist/src/integrations/herdr/dialog.js +64 -0
- package/dist/src/integrations/herdr/focus.js +31 -0
- package/dist/src/integrations/herdr/place.js +22 -0
- package/dist/src/integrations/images.js +43 -0
- package/dist/src/integrations/opencode/install.js +24 -0
- package/dist/src/integrations/opencode/plugin.js +157 -0
- package/dist/src/integrations/pi/extension.js +166 -0
- package/dist/src/integrations/run-tool.js +157 -0
- package/dist/src/integrations/secrets.js +63 -0
- package/dist/src/integrations/tags.js +27 -0
- package/dist/src/integrations/tools.js +147 -0
- package/dist/src/node/home.js +20 -0
- package/dist/src/node/install.js +225 -0
- package/dist/src/node/links.js +249 -0
- package/dist/src/node/pin-prompt.js +125 -0
- package/dist/src/node/server.js +320 -0
- package/dist/src/node/service.js +74 -0
- package/dist/src/node/telemetry.js +127 -0
- package/dist/src/owner-watch.js +49 -0
- package/dist/src/tui/api.js +203 -0
- package/dist/src/tui/app.js +586 -0
- package/dist/src/tui/body.js +727 -0
- package/dist/src/tui/cardview.js +453 -0
- package/dist/src/tui/doc.js +157 -0
- package/dist/src/tui/edit.js +119 -0
- package/dist/src/tui/input.js +192 -0
- package/dist/src/tui/md.js +196 -0
- package/dist/src/tui/screen.js +225 -0
- package/dist/src/tui/terminal.js +130 -0
- package/dist/src/tui/text.js +103 -0
- package/dist/src/tui/theme.js +105 -0
- package/dist/src/tui/view.js +314 -0
- package/dist/src/watch.js +104 -0
- package/dist/web/age.js +16 -0
- package/dist/web/answers.js +485 -0
- package/dist/web/app.js +739 -0
- package/dist/web/body-faults.js +69 -0
- package/dist/web/components.js +923 -0
- package/dist/web/copy.js +118 -0
- package/dist/web/details.js +59 -0
- package/dist/web/env.js +97 -0
- package/dist/web/favicon-waiting.svg +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +16 -0
- package/dist/web/library-spec.js +33 -0
- package/dist/web/markdown.js +142 -0
- package/dist/web/paste-images.js +193 -0
- package/dist/web/pin-key.js +125 -0
- package/dist/web/pin.js +126 -0
- package/dist/web/queue.js +97 -0
- package/dist/web/sealed.js +13 -0
- package/dist/web/search.js +70 -0
- package/dist/web/stories/cards.stories.js +144 -0
- package/dist/web/stories/page.stories.js +54 -0
- package/dist/web/stories/render.js +16 -0
- package/dist/web/style.css +417 -0
- package/package.json +4 -2
- package/src/core/version.ts +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// One inbox as one Durable Object. All its changes go through this object one
|
|
2
|
+
// at a time, and it hibernates when idle: agent sockets stay open, and the
|
|
3
|
+
// object rebuilds each connection from the socket's attachment when it wakes.
|
|
4
|
+
import { COOKIE, createHandler } from '../core/http.js';
|
|
5
|
+
import { Inbox } from '../core/inbox.js';
|
|
6
|
+
import { pinKeyError, } from '../core/pin.js';
|
|
7
|
+
import { agentSocket, pageSocket, } from '../core/socket.js';
|
|
8
|
+
import { dataPoint } from '../core/telemetry.js';
|
|
9
|
+
|
|
10
|
+
import { DurableSqlStorage, R2Blobs } from './storage.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/** What each agent socket keeps across hibernation. */
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/** An unset limit means none. A value that is not a byte count blocks every upload, so a typo never lifts the cap. */
|
|
36
|
+
export function uploadLimit(value ) {
|
|
37
|
+
if (value === undefined || value.trim() === '') return undefined;
|
|
38
|
+
const n = Number(value);
|
|
39
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A socket closes when it has not answered a heartbeat for this long. Each check wakes the object,
|
|
43
|
+
* so the limit trades how soon a silent agent shows offline against wakes: about 300 a day with a socket open. */
|
|
44
|
+
const HEARTBEAT_LIMIT_MS = 5 * 60_000;
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/** A socket that has not signed in this long after it opened closes. */
|
|
48
|
+
const AUTH_LIMIT_MS = 10_000;
|
|
49
|
+
/** The most agent sockets one inbox serves at once. The platform's own ceiling is 32,768 hibernating sockets per
|
|
50
|
+
* object, so this is headroom we choose, not a limit we inherit. */
|
|
51
|
+
const MAX_AGENT_SOCKETS = 512;
|
|
52
|
+
/** The most page sockets. They are counted apart from the agents' budget, so no fleet of agents can take the slot the
|
|
53
|
+
* owner's page needs. */
|
|
54
|
+
const MAX_PAGE_SOCKETS = 16;
|
|
55
|
+
/** A stamp of the object's own ping clock re-writes the socket's attachment at most this often. A page
|
|
56
|
+
* pings every 2 seconds and the deadline is minutes away, so a finer stamp buys nothing. */
|
|
57
|
+
const PING_STAMP_MS = 10_000;
|
|
58
|
+
|
|
59
|
+
/** Read a numeric limit from the environment. */
|
|
60
|
+
function limit(value ) {
|
|
61
|
+
return uploadLimit(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Redeems invite codes one at a time, so a code with one use left creates one inbox, however many requests race.
|
|
65
|
+
* The operator sets the uses in KV; this object counts the uses taken. */
|
|
66
|
+
export class RegistryObject {
|
|
67
|
+
ctx ;
|
|
68
|
+
env ;
|
|
69
|
+
|
|
70
|
+
constructor(ctx , env ) {
|
|
71
|
+
this.ctx = ctx;
|
|
72
|
+
this.env = env;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async fetch(req ) {
|
|
76
|
+
const { code } = (await req.json()) ;
|
|
77
|
+
const allowed = Number(await this.env.INVITES.get(`invite:${String(code ?? '')}`)) || 0;
|
|
78
|
+
// Durable Object storage calls queue behind each other, so this read and write cannot interleave with another request's.
|
|
79
|
+
const used = (await this.ctx.storage.get (`used:${code}`)) ?? 0;
|
|
80
|
+
if (!code || used >= allowed) return Response.json({ ok: false });
|
|
81
|
+
await this.ctx.storage.put(`used:${code}`, used + 1);
|
|
82
|
+
return Response.json({ ok: true, left: allowed - used - 1 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class InboxObject {
|
|
87
|
+
ctx ;
|
|
88
|
+
env ;
|
|
89
|
+
inbox ;
|
|
90
|
+
handle ;
|
|
91
|
+
sockets = new Map ();
|
|
92
|
+
|
|
93
|
+
constructor(ctx , env ) {
|
|
94
|
+
this.ctx = ctx;
|
|
95
|
+
this.env = env;
|
|
96
|
+
this.inbox = new Inbox(new DurableSqlStorage(ctx.storage.sql), Date.now, new R2Blobs(env.FILES, ctx.id.toString()), {
|
|
97
|
+
keepPresence: true,
|
|
98
|
+
uploadLimitBytes: uploadLimit(env.UPLOAD_LIMIT_BYTES),
|
|
99
|
+
storageLimitBytes: limit(env.STORAGE_LIMIT_BYTES),
|
|
100
|
+
shownCardLimit: limit(env.SHOWN_CARDS_LIMIT),
|
|
101
|
+
attachmentReadsPerDay: limit(env.ATTACHMENT_READS_PER_DAY),
|
|
102
|
+
// A request gets 10 ms of CPU time on the Workers Free plan, too little to parse a large body or a diagram.
|
|
103
|
+
// So the posting client checks them, and the page reports what it cannot draw. See SPEC.md, "Checks on the client".
|
|
104
|
+
clientChecks: true,
|
|
105
|
+
// The hosted service always counts usage. A single-inbox deployment sends nothing.
|
|
106
|
+
usageAlwaysOn: env.MODE === 'hosted',
|
|
107
|
+
});
|
|
108
|
+
// Open requests keep the object awake and billed, so events go over hibernating WebSockets only.
|
|
109
|
+
this.handle = createHandler(this.inbox, { heartbeatMs: 2000, streams: false, telemetry: { source: 'hosted', destination: 'the operator of this hosted service', blocked: () => this.usageBlocked() } });
|
|
110
|
+
// Heartbeats get their answer without waking the object.
|
|
111
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('{"type":"ping"}', '{"type":"pong"}'));
|
|
112
|
+
// Each socket here was open before the object hibernated, and its client already got its hello.
|
|
113
|
+
for (const ws of ctx.getWebSockets()) this.attach(ws, true);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `resumed` is set for a socket the object rebuilds after waking, as opposed to one it just accepted. */
|
|
117
|
+
attach(ws , resumed = false) {
|
|
118
|
+
const a = ws.deserializeAttachment() ;
|
|
119
|
+
if (a.page) {
|
|
120
|
+
const conn = pageSocket(this.inbox, { send: (t) => ws.send(t), close: (c, r) => ws.close(c, r) }, a.viewer, a.gated ? { unlock: a.unlock } : undefined);
|
|
121
|
+
this.sockets.set(ws, conn);
|
|
122
|
+
return conn;
|
|
123
|
+
}
|
|
124
|
+
const caller = a.token && a.role ? { token: a.token, role: a.role } : undefined;
|
|
125
|
+
const conn = agentSocket(this.inbox, a.agent, { send: (t) => ws.send(t), close: (c, r) => ws.close(c, r) }, caller, a.instance, resumed, a.channel === 'off');
|
|
126
|
+
this.sockets.set(ws, conn);
|
|
127
|
+
return conn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Why this deployment sends no usage counts. Only the hosted service sends them, to its own operator. */
|
|
131
|
+
usageBlocked() {
|
|
132
|
+
if (this.env.MODE !== 'hosted') return 'a single-inbox deployment sends nothing';
|
|
133
|
+
if (!this.env.TELEMETRY) return 'this deployment has no usage dataset';
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Write each finished day to Analytics Engine. A hosted inbox always sends: its Usage reads as enabled whatever is stored. */
|
|
138
|
+
sendUsage() {
|
|
139
|
+
const usage = this.inbox.usage;
|
|
140
|
+
const due = usage.due();
|
|
141
|
+
if (!due.length) return;
|
|
142
|
+
const s = usage.settings();
|
|
143
|
+
for (const d of due) {
|
|
144
|
+
if (s.enabled && !this.usageBlocked()) this.env.TELEMETRY?.writeDataPoint(dataPoint(usage.report(d, 'hosted')));
|
|
145
|
+
usage.done(d.day);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** When this socket closes unless it signs in or answers a heartbeat first, and the clock that set that moment.
|
|
150
|
+
* Two clocks track a socket: the platform's timestamp of the last auto-response pong, and the object's stamp of
|
|
151
|
+
* the last ping it answered itself. Each covers the time the other may not see, so the later one counts. */
|
|
152
|
+
socketClock(ws , now ) {
|
|
153
|
+
const a = ws.deserializeAttachment() ;
|
|
154
|
+
const opened = a.connectedAt ?? now;
|
|
155
|
+
const ping = a.lastPing ?? null;
|
|
156
|
+
const pong = this.ctx.getWebSocketAutoResponseTimestamp(ws)?.getTime() ?? null;
|
|
157
|
+
let seen = opened;
|
|
158
|
+
let source = 'open';
|
|
159
|
+
if (pong !== null) { seen = pong; source = 'pong'; }
|
|
160
|
+
if (ping !== null && ping >= seen) { seen = ping; source = 'ping'; }
|
|
161
|
+
// A socket that answers pings but never signed in would stay open for free, so it gets a deadline.
|
|
162
|
+
const auth = a.token ? Infinity : opened + AUTH_LIMIT_MS;
|
|
163
|
+
if (auth < seen + HEARTBEAT_LIMIT_MS) return { deadline: auth, source: 'auth', ping, pong };
|
|
164
|
+
return { deadline: seen + HEARTBEAT_LIMIT_MS, source, ping, pong };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** When this socket closes unless it signs in or answers a heartbeat first. */
|
|
168
|
+
socketDeadline(ws , now ) {
|
|
169
|
+
return this.socketClock(ws, now).deadline;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Set the alarm for the next moment `sweep()` has work or a socket reaches its deadline. */
|
|
173
|
+
async schedule() {
|
|
174
|
+
this.sendUsage();
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const at = Math.min(this.inbox.nextDeadline() ?? Infinity, ...this.ctx.getWebSockets().map((ws) => this.socketDeadline(ws, now)));
|
|
177
|
+
if (at !== Infinity) await this.ctx.storage.setAlarm(Math.max(at, now + 50));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The open sockets of one kind. A page socket carries its tag from the day it was accepted; one accepted before the
|
|
181
|
+
* tags existed is an agent's, which is what it was. */
|
|
182
|
+
socketsOf(kind ) {
|
|
183
|
+
return this.ctx.getWebSockets().filter((ws) => (this.ctx.getTags(ws).includes('page') ? 'page' : 'agent') === kind);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async fetch(req ) {
|
|
187
|
+
const url = new URL(req.url);
|
|
188
|
+
if (url.pathname === '/_init') {
|
|
189
|
+
// The Worker calls this once per inbox. The object is never reachable from outside without the Worker.
|
|
190
|
+
const { ownerToken, usage, pin, neverAsk } = (await req.json()) ;
|
|
191
|
+
// A hosted inbox learns its invite id here.
|
|
192
|
+
if (usage) this.inbox.usage.update({ invite: String(usage.invite ?? '') });
|
|
193
|
+
if (ownerToken) {
|
|
194
|
+
try {
|
|
195
|
+
if (!(await this.inbox.authenticate(ownerToken))) await this.inbox.adoptToken(ownerToken, 'owner from the deployment', 'owner');
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return Response.json({ error: `the OWNER_TOKEN secret is wrong: ${(e ).message}` }, { status: 500 });
|
|
198
|
+
}
|
|
199
|
+
return Response.json({ ok: true });
|
|
200
|
+
}
|
|
201
|
+
// The Worker checked the key's form already. A key the object refuses creates no inbox.
|
|
202
|
+
const pinError = pin === undefined ? undefined : pinKeyError(pin, this.inbox.pin.iterations);
|
|
203
|
+
if (pinError) return Response.json({ error: `pin: ${pinError}` }, { status: 400 });
|
|
204
|
+
const { token, secret } = await this.inbox.createToken('owner of this hosted inbox', 'owner');
|
|
205
|
+
// The landing page set the PIN as it created the inbox, and the tab it opens in starts unlocked.
|
|
206
|
+
if (neverAsk === true) this.inbox.pin.setNeverAsk(true);
|
|
207
|
+
if (pin === undefined) return Response.json({ owner: secret });
|
|
208
|
+
// The landing page derived the key. The object stores only its SHA-256.
|
|
209
|
+
await this.inbox.pin.setKey(pin);
|
|
210
|
+
return Response.json({ owner: secret, unlock: await this.inbox.pin.issue(token.id) });
|
|
211
|
+
}
|
|
212
|
+
if (url.pathname === '/v1/ws' && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
213
|
+
if (url.searchParams.get('page') === '1') {
|
|
214
|
+
// The page signs in with its cookie, which the browser sends with the upgrade.
|
|
215
|
+
const auth = req.headers.get('authorization');
|
|
216
|
+
const secret = auth?.startsWith('Bearer ') ? auth.slice(7).trim() : cookieValue(req.headers.get('cookie'), COOKIE);
|
|
217
|
+
const token = await this.inbox.authenticate(secret);
|
|
218
|
+
if (token?.role !== 'owner') return new Response('the page needs the owner token', { status: 401 });
|
|
219
|
+
// A browser cannot set headers on a WebSocket, so the page sends its unlock token in the address.
|
|
220
|
+
const gate = await this.inbox.pinGate(token, !auth?.startsWith('Bearer '), url.searchParams.get('unlock'));
|
|
221
|
+
if (gate.locked) return new Response('this inbox is locked: type its PIN to open it', { status: 423 });
|
|
222
|
+
// A page is never refused for an agent's sake: the two budgets are counted apart, and nothing active is
|
|
223
|
+
// killed to admit a newcomer.
|
|
224
|
+
if (this.socketsOf('page').length >= MAX_PAGE_SOCKETS) return new Response('too many open page sockets', { status: 503 });
|
|
225
|
+
const pair = new WebSocketPair();
|
|
226
|
+
const server = pair[1];
|
|
227
|
+
this.ctx.acceptWebSocket(server, ['page']);
|
|
228
|
+
const viewer = url.searchParams.get('viewer') ?? undefined;
|
|
229
|
+
server.serializeAttachment({ page: true, viewer: viewer && /^[\w-]{4,64}$/.test(viewer) ? viewer : undefined, gated: gate.gated, unlock: gate.unlock, agent: '', token: token.id, role: token.role, connectedAt: Date.now() } );
|
|
230
|
+
this.attach(server);
|
|
231
|
+
await this.schedule();
|
|
232
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
233
|
+
}
|
|
234
|
+
const agent = url.searchParams.get('agent');
|
|
235
|
+
if (!agent || agent.length > 80) return new Response('agent is required, at most 80 characters', { status: 400 });
|
|
236
|
+
const auth = req.headers.get('authorization');
|
|
237
|
+
const token = auth?.startsWith('Bearer ') ? await this.inbox.authenticate(auth.slice(7).trim()) : undefined;
|
|
238
|
+
if (auth && !token) return new Response('unknown token', { status: 401 });
|
|
239
|
+
// Over budget, the newcomer is refused and its client retries; no working session is disconnected to admit it.
|
|
240
|
+
// Closing an active socket is a denial of service on the agent that holds it, and what it would lose is only
|
|
241
|
+
// a link: the answers it missed are in storage.
|
|
242
|
+
if (this.socketsOf('agent').length >= MAX_AGENT_SOCKETS) return new Response('too many open sockets', { status: 503 });
|
|
243
|
+
const pair = new WebSocketPair();
|
|
244
|
+
const server = pair[1];
|
|
245
|
+
this.ctx.acceptWebSocket(server, ['agent']);
|
|
246
|
+
server.serializeAttachment({ agent, instance: url.searchParams.get('instance') ?? undefined, channel: url.searchParams.get('channel') === 'off' ? 'off' : undefined, token: token?.id, role: token?.role, connectedAt: Date.now() } );
|
|
247
|
+
this.attach(server);
|
|
248
|
+
await this.schedule();
|
|
249
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
250
|
+
}
|
|
251
|
+
// The page probes this address with a plain request to classify a failed upgrade, which a browser reports without
|
|
252
|
+
// a status. 200 says this build serves a page socket here.
|
|
253
|
+
if (url.pathname === '/v1/ws' && req.method === 'GET' && req.headers.get('upgrade') === null) {
|
|
254
|
+
await this.schedule();
|
|
255
|
+
return Response.json({ webSocket: true });
|
|
256
|
+
}
|
|
257
|
+
const res = await this.handle(req);
|
|
258
|
+
await this.schedule();
|
|
259
|
+
// An answer calls its webhook at once. When the call fails, the alarm moves to its retry.
|
|
260
|
+
const calls = [...this.inbox.deliveries];
|
|
261
|
+
if (calls.length) this.ctx.waitUntil?.(Promise.allSettled(calls).then(() => this.schedule()));
|
|
262
|
+
return res;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async webSocketMessage(ws , message ) {
|
|
266
|
+
const conn = this.sockets.get(ws) ?? this.attach(ws, true);
|
|
267
|
+
const agent = 'caller' in conn ? conn : undefined;
|
|
268
|
+
const before = agent?.authenticated ?? true;
|
|
269
|
+
const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
|
|
270
|
+
// An awake object answers the ping itself, and the platform may not move its auto-response clock then,
|
|
271
|
+
// so the object stamps its own clock on the socket before it answers.
|
|
272
|
+
if (text.includes('"ping"')) {
|
|
273
|
+
const a = ws.deserializeAttachment() ;
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
if (now - (a.lastPing ?? 0) >= PING_STAMP_MS) ws.serializeAttachment({ ...a, lastPing: now } );
|
|
276
|
+
}
|
|
277
|
+
await conn.message(text);
|
|
278
|
+
// A client that authenticated in its first frame keeps that across hibernation.
|
|
279
|
+
if (!before && agent?.caller) {
|
|
280
|
+
const a = ws.deserializeAttachment() ;
|
|
281
|
+
ws.serializeAttachment({ ...a, token: agent.caller.token, role: agent.caller.role } );
|
|
282
|
+
}
|
|
283
|
+
await this.schedule();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async webSocketClose(ws ) {
|
|
287
|
+
this.sockets.get(ws)?.close();
|
|
288
|
+
this.sockets.delete(ws);
|
|
289
|
+
await this.schedule();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async webSocketError(ws ) {
|
|
293
|
+
await this.webSocketClose(ws);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The platform alarm: sweep the inbox, purge old cards, call the answer webhooks that are due, and close sockets that did
|
|
297
|
+
* not sign in or stopped answering heartbeats. */
|
|
298
|
+
async alarm() {
|
|
299
|
+
// Everything runs inside try/finally: a throw in the sweep used to skip the rescheduling below, and once the
|
|
300
|
+
// platform's six retries ran out nothing swept again, so a socket it had stopped tracking stayed counted for good.
|
|
301
|
+
try {
|
|
302
|
+
this.inbox.sweep();
|
|
303
|
+
await this.inbox.purge();
|
|
304
|
+
await this.inbox.deliverAnswers();
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
307
|
+
const clock = this.socketClock(ws, now);
|
|
308
|
+
if (clock.deadline > now) continue;
|
|
309
|
+
const a = ws.deserializeAttachment() ;
|
|
310
|
+
// One structured line per close. It records both clocks, so a production close shows whether the platform's
|
|
311
|
+
// pong timestamp moves while the object is awake, which the WebSocket hibernation docs leave open.
|
|
312
|
+
console.log(JSON.stringify({
|
|
313
|
+
event: 'socket-close',
|
|
314
|
+
...(a.page ? { page: true } : { agent: a.agent, instance: a.instance }),
|
|
315
|
+
deadline: clock.deadline,
|
|
316
|
+
now,
|
|
317
|
+
clock: clock.source,
|
|
318
|
+
ping: clock.ping,
|
|
319
|
+
pong: clock.pong,
|
|
320
|
+
}));
|
|
321
|
+
this.sockets.get(ws)?.close();
|
|
322
|
+
this.sockets.delete(ws);
|
|
323
|
+
ws.close(1001, 'missed heartbeats');
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
await this.schedule();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cookieValue(header , name ) {
|
|
332
|
+
for (const part of (header ?? '').split(';')) {
|
|
333
|
+
const [k, ...v] = part.trim().split('=');
|
|
334
|
+
if (k !== name) continue;
|
|
335
|
+
try { return decodeURIComponent(v.join('=')); } catch { return undefined; }
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Storage for the Cloudflare build: the SQLite database of a Durable Object, and R2 for file bytes.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/** The part of a Durable Object's SqlStorage this adapter uses. Its calls are synchronous. */
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export class DurableSqlStorage {
|
|
10
|
+
sql ;
|
|
11
|
+
fts ;
|
|
12
|
+
/** The bytes of values and search text, counted once and then kept current with each write. */
|
|
13
|
+
used ;
|
|
14
|
+
|
|
15
|
+
constructor(sql ) {
|
|
16
|
+
this.sql = sql;
|
|
17
|
+
sql.exec('CREATE TABLE IF NOT EXISTS docs (collection TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (collection, id))');
|
|
18
|
+
// Each entry of an index on the collection ends with the rowid, so `first` reads a collection in write order and stops.
|
|
19
|
+
sql.exec('CREATE INDEX IF NOT EXISTS docs_order ON docs (collection)');
|
|
20
|
+
let fts = true;
|
|
21
|
+
try {
|
|
22
|
+
sql.exec('CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(card UNINDEXED, text)');
|
|
23
|
+
// FTS5 finds a row fast only by its row id, so `search_rows` gives each card the row id of its entry. An inbox
|
|
24
|
+
// indexed before the map existed fills it once, the first time this table is created.
|
|
25
|
+
try {
|
|
26
|
+
sql.exec('CREATE TABLE search_rows (row INTEGER PRIMARY KEY, card TEXT NOT NULL UNIQUE)');
|
|
27
|
+
sql.exec('INSERT OR IGNORE INTO search_rows (row, card) SELECT rowid, card FROM search');
|
|
28
|
+
} catch {
|
|
29
|
+
// The map exists already.
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Without FTS5, search falls back to a table matched with LIKE.
|
|
33
|
+
fts = false;
|
|
34
|
+
sql.exec('CREATE TABLE IF NOT EXISTS search (card TEXT PRIMARY KEY, text TEXT NOT NULL)');
|
|
35
|
+
}
|
|
36
|
+
this.fts = fts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get (collection , id ) {
|
|
40
|
+
const row = this.sql.exec('SELECT value FROM docs WHERE collection = ? AND id = ?', collection, id).toArray()[0];
|
|
41
|
+
return row ? (JSON.parse(String(row['value'])) ) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
bytes() {
|
|
45
|
+
this.used ??= Number(this.sql.exec('SELECT (SELECT COALESCE(SUM(length(value)), 0) FROM docs) + (SELECT COALESCE(SUM(length(text)), 0) FROM search) AS n').toArray()[0]?.['n'] ?? 0);
|
|
46
|
+
return this.used;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
change(delta ) {
|
|
50
|
+
if (this.used !== undefined) this.used = Math.max(0, this.used + delta);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
size(collection , id ) {
|
|
54
|
+
return Number(this.sql.exec('SELECT length(value) AS n FROM docs WHERE collection = ? AND id = ?', collection, id).toArray()[0]?.['n'] ?? 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
put (collection , id , value ) {
|
|
58
|
+
const text = JSON.stringify(value);
|
|
59
|
+
// SQLite measures the sizes before and after, so the count matches what length() reports.
|
|
60
|
+
const old = this.used === undefined ? 0 : this.size(collection, id);
|
|
61
|
+
this.sql.exec('INSERT INTO docs (collection, id, value) VALUES (?, ?, ?) ON CONFLICT (collection, id) DO UPDATE SET value = excluded.value', collection, id, text);
|
|
62
|
+
if (this.used !== undefined) this.change(this.size(collection, id) - old);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
delete(collection , id ) {
|
|
66
|
+
if (this.used !== undefined) this.change(-this.size(collection, id));
|
|
67
|
+
this.sql.exec('DELETE FROM docs WHERE collection = ? AND id = ?', collection, id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list (collection ) {
|
|
71
|
+
return this.sql.exec('SELECT value FROM docs WHERE collection = ? ORDER BY rowid', collection).toArray().map((r) => JSON.parse(String(r['value'])) );
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
first (collection , limit ) {
|
|
75
|
+
return this.sql.exec('SELECT value FROM docs WHERE collection = ? ORDER BY rowid LIMIT ?', collection, limit).toArray().map((r) => JSON.parse(String(r['value'])) );
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
count(collection ) {
|
|
79
|
+
return Number(this.sql.exec('SELECT COUNT(*) AS n FROM docs WHERE collection = ?', collection).toArray()[0]?.['n'] ?? 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
index(card , text ) {
|
|
83
|
+
if (!this.fts) return this.indexPlain(card, text);
|
|
84
|
+
const row = this.sql.exec('SELECT row FROM search_rows WHERE card = ?', card).toArray()[0]?.['row'];
|
|
85
|
+
const indexed = () => Number(this.sql.exec('SELECT length(text) AS n FROM search WHERE rowid = (SELECT row FROM search_rows WHERE card = ?)', card).toArray()[0]?.['n'] ?? 0);
|
|
86
|
+
const old = this.used === undefined || row === undefined ? 0 : indexed();
|
|
87
|
+
if (row !== undefined) this.sql.exec('DELETE FROM search WHERE rowid = ?', row);
|
|
88
|
+
if (text) {
|
|
89
|
+
this.sql.exec('INSERT INTO search_rows (card) VALUES (?) ON CONFLICT (card) DO NOTHING', card);
|
|
90
|
+
this.sql.exec('INSERT INTO search (rowid, card, text) VALUES ((SELECT row FROM search_rows WHERE card = ?), ?, ?)', card, card, text);
|
|
91
|
+
} else if (row !== undefined) {
|
|
92
|
+
this.sql.exec('DELETE FROM search_rows WHERE card = ?', card);
|
|
93
|
+
}
|
|
94
|
+
if (this.used !== undefined) this.change((text ? indexed() : 0) - old);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The search table without FTS5 has the card as its key. */
|
|
98
|
+
indexPlain(card , text ) {
|
|
99
|
+
const indexed = () => Number(this.sql.exec('SELECT COALESCE(SUM(length(text)), 0) AS n FROM search WHERE card = ?', card).toArray()[0]?.['n'] ?? 0);
|
|
100
|
+
const old = this.used === undefined ? 0 : indexed();
|
|
101
|
+
this.sql.exec('DELETE FROM search WHERE card = ?', card);
|
|
102
|
+
if (text) this.sql.exec('INSERT INTO search (card, text) VALUES (?, ?)', card, text);
|
|
103
|
+
if (this.used !== undefined) this.change(indexed() - old);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
search(query , limit ) {
|
|
107
|
+
const words = query.split(/\s+/).map((w) => w.replace(/["%_]/g, '')).filter(Boolean);
|
|
108
|
+
if (!words.length) return [];
|
|
109
|
+
if (this.fts) {
|
|
110
|
+
return this.sql.exec('SELECT card, rank FROM search WHERE search MATCH ? ORDER BY rank LIMIT ?', words.map((w) => `"${w}"*`).join(' '), limit)
|
|
111
|
+
.toArray().map((r) => ({ card: String(r['card']), rank: Number(r['rank']) }));
|
|
112
|
+
}
|
|
113
|
+
const where = words.map(() => 'text LIKE ?').join(' AND ');
|
|
114
|
+
return this.sql.exec(`SELECT card FROM search WHERE ${where} LIMIT ?`, ...words.map((w) => `%${w}%`), limit)
|
|
115
|
+
.toArray().map((r) => ({ card: String(r['card']), rank: 0 }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
close() {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The part of an R2 bucket this adapter uses. */
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/** File bytes in R2, under one prefix per inbox. */
|
|
129
|
+
export class R2Blobs {
|
|
130
|
+
bucket ;
|
|
131
|
+
prefix ;
|
|
132
|
+
|
|
133
|
+
constructor(bucket , prefix ) {
|
|
134
|
+
this.bucket = bucket;
|
|
135
|
+
this.prefix = prefix;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async put(key , bytes , type ) {
|
|
139
|
+
await this.bucket.put(`${this.prefix}/${key}`, bytes, { httpMetadata: { contentType: type } });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async get(key ) {
|
|
143
|
+
const obj = await this.bucket.get(`${this.prefix}/${key}`);
|
|
144
|
+
if (!obj) return undefined;
|
|
145
|
+
return { bytes: new Uint8Array(await obj.arrayBuffer()), type: obj.httpMetadata?.contentType ?? 'application/octet-stream' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async delete(key ) {
|
|
149
|
+
await this.bucket.delete(`${this.prefix}/${key}`);
|
|
150
|
+
}
|
|
151
|
+
}
|