@frockbot/cloudflare 0.0.0 → 0.7.292
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
package/src/push.ts
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { BRAND_V1 } from "#brand";
|
|
2
|
+
import { isPublicIdentifier } from "@frockbot/core/configuration";
|
|
3
|
+
import { withDeadlineV1 } from "@frockbot/core/deadline";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which app holds an FCM token. An iPhone's token reaches APNs, which draws
|
|
7
|
+
* the alert itself and needs an `apns` block to say what to draw; an Android
|
|
8
|
+
* app draws its own from the data alone.
|
|
9
|
+
*/
|
|
10
|
+
export type PushPlatformV1 = "android" | "ios";
|
|
11
|
+
const PUSH_PLATFORMS_V1: readonly PushPlatformV1[] = ["android", "ios"];
|
|
12
|
+
|
|
13
|
+
export interface PushDevice {
|
|
14
|
+
deviceId: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
/** Set with the token it describes. */
|
|
17
|
+
platform?: PushPlatformV1;
|
|
18
|
+
activeBotId?: string;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
}
|
|
21
|
+
export interface PushUpdate {
|
|
22
|
+
botId: string;
|
|
23
|
+
/**
|
|
24
|
+
* A Group Chat message: `botId` is its author, and the group is what the
|
|
25
|
+
* alert opens, what a device reading it defers to, and whose cursor it is.
|
|
26
|
+
*/
|
|
27
|
+
groupId?: string;
|
|
28
|
+
cursor: string;
|
|
29
|
+
kind: "message" | "read";
|
|
30
|
+
title?: string;
|
|
31
|
+
body?: string;
|
|
32
|
+
notify?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface PushRegistration {
|
|
35
|
+
deviceId: string;
|
|
36
|
+
token?: string;
|
|
37
|
+
platform?: PushPlatformV1;
|
|
38
|
+
activeBotId?: string;
|
|
39
|
+
remove?: boolean;
|
|
40
|
+
}
|
|
41
|
+
const DEVICE_PREFIX = "push:device:";
|
|
42
|
+
const DELIVERY_PREFIX = "push:delivery:";
|
|
43
|
+
const PRESENCE_MS = 15_000;
|
|
44
|
+
|
|
45
|
+
export function decodePushRegistration(input: unknown): PushRegistration {
|
|
46
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
47
|
+
throw new Error("Invalid push registration");
|
|
48
|
+
const value = input as Record<string, unknown>;
|
|
49
|
+
if (
|
|
50
|
+
Object.keys(value).some(
|
|
51
|
+
(key) =>
|
|
52
|
+
!["deviceId", "token", "platform", "activeBotId", "remove"].includes(
|
|
53
|
+
key,
|
|
54
|
+
),
|
|
55
|
+
) ||
|
|
56
|
+
!isPublicIdentifier(value.deviceId)
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Invalid device id");
|
|
59
|
+
if (
|
|
60
|
+
value.token !== undefined &&
|
|
61
|
+
(typeof value.token !== "string" ||
|
|
62
|
+
value.token.length < 20 ||
|
|
63
|
+
value.token.length > 4096)
|
|
64
|
+
)
|
|
65
|
+
throw new Error("Invalid push token");
|
|
66
|
+
// A platform describes a token, so it never arrives without one.
|
|
67
|
+
if (
|
|
68
|
+
value.platform !== undefined &&
|
|
69
|
+
(value.token === undefined ||
|
|
70
|
+
!PUSH_PLATFORMS_V1.includes(value.platform as PushPlatformV1))
|
|
71
|
+
)
|
|
72
|
+
throw new Error("Invalid push platform");
|
|
73
|
+
if (value.activeBotId !== undefined && !isPublicIdentifier(value.activeBotId))
|
|
74
|
+
throw new Error("Invalid active Bot");
|
|
75
|
+
if (value.remove !== undefined && typeof value.remove !== "boolean")
|
|
76
|
+
throw new Error("Invalid remove flag");
|
|
77
|
+
return value as unknown as PushRegistration;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function registerPushDevice(
|
|
81
|
+
storage: DurableObjectStorage,
|
|
82
|
+
value: PushRegistration,
|
|
83
|
+
now = Date.now(),
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const key = DEVICE_PREFIX + value.deviceId;
|
|
86
|
+
if (value.remove) {
|
|
87
|
+
await forgetDevice(storage, key, value.deviceId);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const devices = await storage.list<PushDevice>({ prefix: DEVICE_PREFIX });
|
|
91
|
+
for (const [oldKey, device] of devices)
|
|
92
|
+
if (
|
|
93
|
+
now - device.updatedAt >
|
|
94
|
+
(device.token ? 30 * 86400_000 : PRESENCE_MS * 4)
|
|
95
|
+
) {
|
|
96
|
+
await forgetDevice(storage, oldKey, device.deviceId);
|
|
97
|
+
devices.delete(oldKey);
|
|
98
|
+
}
|
|
99
|
+
if (!devices.has(key) && devices.size >= 32)
|
|
100
|
+
throw new Error("Too many registered devices");
|
|
101
|
+
// A refreshed token replaces the installation's old token, never adds
|
|
102
|
+
// another recipient. A registration that carries no token is a presence
|
|
103
|
+
// update — the device says which Bot it is reading, from the first frame,
|
|
104
|
+
// before the FCM token has been fetched — so it keeps the token already
|
|
105
|
+
// registered rather than erasing the only address the Bot can reach. Every
|
|
106
|
+
// other field is stated afresh: an omitted `activeBotId` means this device
|
|
107
|
+
// is no longer reading anything, and merging it would suppress its alerts.
|
|
108
|
+
// The platform is the token's, so it is kept or replaced with it.
|
|
109
|
+
const previous = devices.get(key);
|
|
110
|
+
const token = value.token ?? previous?.token;
|
|
111
|
+
const platform =
|
|
112
|
+
value.token === undefined ? previous?.platform : value.platform;
|
|
113
|
+
await storage.put(key, {
|
|
114
|
+
deviceId: value.deviceId,
|
|
115
|
+
...(token === undefined ? {} : { token }),
|
|
116
|
+
...(platform === undefined ? {} : { platform }),
|
|
117
|
+
...(value.activeBotId === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { activeBotId: value.activeBotId }),
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
} satisfies PushDevice);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A device and everything written about it. The delivery receipts are keyed by
|
|
126
|
+
* the device id, so they have to go with it: an install that is replaced or
|
|
127
|
+
* expires would otherwise leave one row per Bot and kind behind for ever.
|
|
128
|
+
*/
|
|
129
|
+
async function forgetDevice(
|
|
130
|
+
storage: DurableObjectStorage,
|
|
131
|
+
key: string,
|
|
132
|
+
deviceId: string,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await storage.delete(key);
|
|
135
|
+
await forgetDeliveries(storage, deviceId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function forgetDeliveries(
|
|
139
|
+
storage: DurableObjectStorage,
|
|
140
|
+
deviceId: string,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const receipts = await storage.list<unknown>({ prefix: DELIVERY_PREFIX });
|
|
143
|
+
for (const receiptKey of receipts.keys())
|
|
144
|
+
if (receiptKey.endsWith(`:${deviceId}`)) await storage.delete(receiptKey);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class RetryablePushError extends Error {}
|
|
148
|
+
|
|
149
|
+
interface ServiceAccount {
|
|
150
|
+
project_id: string;
|
|
151
|
+
client_email: string;
|
|
152
|
+
private_key: string;
|
|
153
|
+
}
|
|
154
|
+
const encoder = new TextEncoder();
|
|
155
|
+
function base64url(bytes: Uint8Array): string {
|
|
156
|
+
return btoa(String.fromCharCode(...bytes))
|
|
157
|
+
.replace(/=/g, "")
|
|
158
|
+
.replace(/\+/g, "-")
|
|
159
|
+
.replace(/\//g, "_");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The minted access token, reused until it is nearly expired.
|
|
164
|
+
*
|
|
165
|
+
* The assertion buys an hour; signing and exchanging one per device per message
|
|
166
|
+
* turned a burst into a run of RSA signings and round trips to Google for no
|
|
167
|
+
* gain. A token that stops being accepted is dropped and re-minted rather than
|
|
168
|
+
* cached into a permanent failure.
|
|
169
|
+
*/
|
|
170
|
+
const accessTokens = new Map<string, { token: string; expiresAt: number }>();
|
|
171
|
+
const ACCESS_TOKEN_MARGIN_MS = 300_000;
|
|
172
|
+
|
|
173
|
+
async function accessToken(
|
|
174
|
+
account: ServiceAccount,
|
|
175
|
+
request: typeof fetch,
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
const cached = accessTokens.get(account.client_email);
|
|
178
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
179
|
+
const now = Math.floor(Date.now() / 1000);
|
|
180
|
+
const header = base64url(
|
|
181
|
+
encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT" })),
|
|
182
|
+
);
|
|
183
|
+
const claims = base64url(
|
|
184
|
+
encoder.encode(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
iss: account.client_email,
|
|
187
|
+
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
188
|
+
aud: "https://oauth2.googleapis.com/token",
|
|
189
|
+
iat: now,
|
|
190
|
+
exp: now + 3600,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
const pem = account.private_key
|
|
195
|
+
.replace(/-----[^-]+-----/g, "")
|
|
196
|
+
.replace(/\s/g, "");
|
|
197
|
+
const key = await crypto.subtle.importKey(
|
|
198
|
+
"pkcs8",
|
|
199
|
+
Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)),
|
|
200
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
201
|
+
false,
|
|
202
|
+
["sign"],
|
|
203
|
+
);
|
|
204
|
+
const signature = await crypto.subtle.sign(
|
|
205
|
+
"RSASSA-PKCS1-v1_5",
|
|
206
|
+
key,
|
|
207
|
+
encoder.encode(`${header}.${claims}`),
|
|
208
|
+
);
|
|
209
|
+
const deadline = withDeadlineV1(10_000);
|
|
210
|
+
let access: { access_token: string; expires_in?: number };
|
|
211
|
+
try {
|
|
212
|
+
const auth = await request("https://oauth2.googleapis.com/token", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
215
|
+
body: new URLSearchParams({
|
|
216
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
217
|
+
assertion: `${header}.${claims}.${base64url(new Uint8Array(signature))}`,
|
|
218
|
+
}),
|
|
219
|
+
signal: deadline.signal,
|
|
220
|
+
}).catch(() => {
|
|
221
|
+
throw new RetryablePushError("Push authorization is unavailable");
|
|
222
|
+
});
|
|
223
|
+
if (!auth.ok)
|
|
224
|
+
throw new RetryablePushError(
|
|
225
|
+
`Push authorization failed (${auth.status})`,
|
|
226
|
+
);
|
|
227
|
+
access = (await auth.json()) as {
|
|
228
|
+
access_token: string;
|
|
229
|
+
expires_in?: number;
|
|
230
|
+
};
|
|
231
|
+
} finally {
|
|
232
|
+
deadline.clear();
|
|
233
|
+
}
|
|
234
|
+
accessTokens.set(account.client_email, {
|
|
235
|
+
token: access.access_token,
|
|
236
|
+
expiresAt:
|
|
237
|
+
Date.now() +
|
|
238
|
+
Math.max(
|
|
239
|
+
60_000,
|
|
240
|
+
(access.expires_in ?? 3600) * 1000 - ACCESS_TOKEN_MARGIN_MS,
|
|
241
|
+
),
|
|
242
|
+
});
|
|
243
|
+
return access.access_token;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Read signals, like the Android `ttl`, stop being worth delivering after a day. */
|
|
247
|
+
const DELIVERY_LIFETIME_S = 86_400;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What APNs draws on an iPhone. A suspended app cannot draw an alert from
|
|
251
|
+
* data, so a message to be told arrives as the alert itself, threaded per
|
|
252
|
+
* conversation the way Android keeps one notification per Bot. Everything
|
|
253
|
+
* else — a read on another device, a message not to be told — is a background
|
|
254
|
+
* push that wakes the app if iOS allows it, and that iOS may hold back.
|
|
255
|
+
*/
|
|
256
|
+
function apnsMessage(
|
|
257
|
+
data: Record<string, string>,
|
|
258
|
+
notify: boolean,
|
|
259
|
+
): Record<string, unknown> {
|
|
260
|
+
const expiration = String(
|
|
261
|
+
Math.floor(Date.now() / 1000) + DELIVERY_LIFETIME_S,
|
|
262
|
+
);
|
|
263
|
+
if (!notify)
|
|
264
|
+
return {
|
|
265
|
+
headers: {
|
|
266
|
+
"apns-priority": "5",
|
|
267
|
+
"apns-push-type": "background",
|
|
268
|
+
"apns-expiration": expiration,
|
|
269
|
+
},
|
|
270
|
+
payload: { aps: { "content-available": 1 } },
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
headers: {
|
|
274
|
+
"apns-priority": "10",
|
|
275
|
+
"apns-push-type": "alert",
|
|
276
|
+
"apns-expiration": expiration,
|
|
277
|
+
},
|
|
278
|
+
payload: {
|
|
279
|
+
aps: {
|
|
280
|
+
alert: {
|
|
281
|
+
title: data.title || BRAND_V1.productName,
|
|
282
|
+
body: data.body || "New message",
|
|
283
|
+
},
|
|
284
|
+
sound: "default",
|
|
285
|
+
"thread-id": data.groupId ? `group:${data.groupId}` : data.botId,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function sendFcm(
|
|
292
|
+
secret: string,
|
|
293
|
+
target: { token: string; platform?: PushPlatformV1 },
|
|
294
|
+
data: Record<string, string>,
|
|
295
|
+
notify: boolean,
|
|
296
|
+
request: typeof fetch = fetch,
|
|
297
|
+
): Promise<"sent" | "unregistered"> {
|
|
298
|
+
const account = JSON.parse(secret) as ServiceAccount;
|
|
299
|
+
const bearer = await accessToken(account, request);
|
|
300
|
+
const deadline = withDeadlineV1(10_000);
|
|
301
|
+
let result: Response;
|
|
302
|
+
try {
|
|
303
|
+
result = await request(
|
|
304
|
+
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.project_id)}/messages:send`,
|
|
305
|
+
{
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: {
|
|
308
|
+
Authorization: `Bearer ${bearer}`,
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
},
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
message: {
|
|
313
|
+
token: target.token,
|
|
314
|
+
data,
|
|
315
|
+
android: {
|
|
316
|
+
priority: notify ? "HIGH" : "NORMAL",
|
|
317
|
+
ttl: `${DELIVERY_LIFETIME_S}s`,
|
|
318
|
+
},
|
|
319
|
+
...(target.platform === "ios"
|
|
320
|
+
? { apns: apnsMessage(data, notify) }
|
|
321
|
+
: {}),
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
signal: deadline.signal,
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
} finally {
|
|
328
|
+
deadline.clear();
|
|
329
|
+
}
|
|
330
|
+
if (result.status === 404) {
|
|
331
|
+
const error = (await result.json()) as {
|
|
332
|
+
error?: { details?: { errorCode?: string }[] };
|
|
333
|
+
};
|
|
334
|
+
if (
|
|
335
|
+
error.error?.details?.some(
|
|
336
|
+
(detail) => detail.errorCode === "UNREGISTERED",
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
return "unregistered";
|
|
340
|
+
}
|
|
341
|
+
if (result.status === 401 || result.status === 403) {
|
|
342
|
+
accessTokens.delete(account.client_email);
|
|
343
|
+
throw new RetryablePushError(
|
|
344
|
+
`Push authorization was rejected (${result.status})`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (result.status === 429 || result.status >= 500)
|
|
348
|
+
throw new RetryablePushError(
|
|
349
|
+
`Push service rejected the attempt (${result.status})`,
|
|
350
|
+
);
|
|
351
|
+
if (!result.ok) throw new Error(`Push delivery failed (${result.status})`);
|
|
352
|
+
await result.body?.cancel();
|
|
353
|
+
return "sent";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** One external attempt per message/device; an uncertain send is explicit, never blindly replayed. */
|
|
357
|
+
export async function deliverPush(
|
|
358
|
+
storage: DurableObjectStorage,
|
|
359
|
+
userId: string,
|
|
360
|
+
update: PushUpdate,
|
|
361
|
+
secret: string | undefined,
|
|
362
|
+
sender = sendFcm,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
const devices = await storage.list<PushDevice>({ prefix: DEVICE_PREFIX });
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
for (const [key, device] of devices) {
|
|
367
|
+
if (
|
|
368
|
+
now - device.updatedAt >
|
|
369
|
+
(device.token ? 30 * 86400_000 : PRESENCE_MS * 4)
|
|
370
|
+
) {
|
|
371
|
+
await forgetDevice(storage, key, device.deviceId);
|
|
372
|
+
devices.delete(key);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (![...devices.values()].some((device) => device.token)) return;
|
|
376
|
+
if (!secret) throw new Error("Firebase push is not configured");
|
|
377
|
+
const target = update.groupId ?? update.botId;
|
|
378
|
+
const beingRead = [...devices.values()].some(
|
|
379
|
+
(device) =>
|
|
380
|
+
device.activeBotId === target && now - device.updatedAt < PRESENCE_MS,
|
|
381
|
+
);
|
|
382
|
+
// Presence delays delivery; only a durable read receipt can discard an alert.
|
|
383
|
+
// A stale focus lease must never silently lose a message.
|
|
384
|
+
if (beingRead && update.kind === "message" && update.notify === true)
|
|
385
|
+
throw new RetryablePushError(
|
|
386
|
+
"Waiting for the visible message's read receipt",
|
|
387
|
+
);
|
|
388
|
+
for (const [deviceKey, device] of devices) {
|
|
389
|
+
if (!device.token) continue;
|
|
390
|
+
const key = update.groupId
|
|
391
|
+
? `${DELIVERY_PREFIX}group:${update.groupId}:${update.kind}:${device.deviceId}`
|
|
392
|
+
: `${DELIVERY_PREFIX}${update.botId}:${update.kind}:${device.deviceId}`;
|
|
393
|
+
const claimed = await storage.transaction(async (tx) => {
|
|
394
|
+
const previous = await tx.get<{
|
|
395
|
+
cursor: string;
|
|
396
|
+
status: string;
|
|
397
|
+
at: number;
|
|
398
|
+
retryAt?: number;
|
|
399
|
+
}>(key);
|
|
400
|
+
if (
|
|
401
|
+
previous &&
|
|
402
|
+
previous.cursor >= update.cursor &&
|
|
403
|
+
previous.status === "retry"
|
|
404
|
+
) {
|
|
405
|
+
if (previous.cursor > update.cursor || (previous.retryAt ?? 0) > now)
|
|
406
|
+
throw new RetryablePushError("Push retry is scheduled");
|
|
407
|
+
} else if (previous && previous.cursor >= update.cursor) {
|
|
408
|
+
if (previous.status === "attempting") {
|
|
409
|
+
await tx.put(key, { ...previous, status: "uncertain" });
|
|
410
|
+
console.error(
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
event: "push-delivery-uncertain",
|
|
413
|
+
botId: update.botId,
|
|
414
|
+
cursor: update.cursor,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
await tx.put(key, {
|
|
421
|
+
cursor: update.cursor,
|
|
422
|
+
status: "attempting",
|
|
423
|
+
at: now,
|
|
424
|
+
});
|
|
425
|
+
return true;
|
|
426
|
+
});
|
|
427
|
+
if (!claimed) continue;
|
|
428
|
+
const notify =
|
|
429
|
+
update.kind === "message" && update.notify === true && !beingRead;
|
|
430
|
+
try {
|
|
431
|
+
const result = await sender(
|
|
432
|
+
secret,
|
|
433
|
+
{
|
|
434
|
+
token: device.token,
|
|
435
|
+
...(device.platform ? { platform: device.platform } : {}),
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
userId,
|
|
439
|
+
botId: update.botId,
|
|
440
|
+
...(update.groupId ? { groupId: update.groupId } : {}),
|
|
441
|
+
cursor: update.cursor,
|
|
442
|
+
kind: update.kind,
|
|
443
|
+
title: update.title ?? "",
|
|
444
|
+
body: update.body ?? "",
|
|
445
|
+
notify: String(notify),
|
|
446
|
+
},
|
|
447
|
+
notify,
|
|
448
|
+
);
|
|
449
|
+
if (result === "unregistered") {
|
|
450
|
+
const removed = await storage.transaction(async (tx) => {
|
|
451
|
+
if ((await tx.get<PushDevice>(deviceKey))?.token !== device.token)
|
|
452
|
+
return false;
|
|
453
|
+
await tx.delete(deviceKey);
|
|
454
|
+
return true;
|
|
455
|
+
});
|
|
456
|
+
if (removed) {
|
|
457
|
+
await forgetDeliveries(storage, device.deviceId);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
await finishDelivery(storage, key, update.cursor, result, now);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error instanceof RetryablePushError) {
|
|
464
|
+
await storage.transaction(async (tx) => {
|
|
465
|
+
if ((await tx.get<{ cursor: string }>(key))?.cursor === update.cursor)
|
|
466
|
+
await tx.put(key, {
|
|
467
|
+
cursor: update.cursor,
|
|
468
|
+
status: "retry",
|
|
469
|
+
at: now,
|
|
470
|
+
retryAt: now + 60_000,
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
await finishDelivery(storage, key, update.cursor, "uncertain", now);
|
|
476
|
+
console.error(
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
event: "push-delivery-uncertain",
|
|
479
|
+
botId: update.botId,
|
|
480
|
+
cursor: update.cursor,
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function finishDelivery(
|
|
488
|
+
storage: DurableObjectStorage,
|
|
489
|
+
key: string,
|
|
490
|
+
cursor: string,
|
|
491
|
+
status: string,
|
|
492
|
+
at: number,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
await storage.transaction(async (tx) => {
|
|
495
|
+
if ((await tx.get<{ cursor: string }>(key))?.cursor === cursor)
|
|
496
|
+
await tx.put(key, { cursor, status, at });
|
|
497
|
+
});
|
|
498
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering a request without reading it.
|
|
3
|
+
*
|
|
4
|
+
* workerd tears the whole isolate down — "Can't read from request stream after
|
|
5
|
+
* response has been sent" — when a handler answers a request whose body it
|
|
6
|
+
* never touched. Every early return is such a handler: a body refused for its
|
|
7
|
+
* size before it was parsed, a bad origin, a 404 on a POST. A 111 KB message
|
|
8
|
+
* took the dev stack down twice this way, because the size refusal that
|
|
9
|
+
* answered it 400 was the one path guaranteed never to read the body.
|
|
10
|
+
*
|
|
11
|
+
* So the drain is not per-route diligence. It is one helper on the outermost
|
|
12
|
+
* wrapper of each Worker in the request path — the gateway and the loaded User
|
|
13
|
+
* application are separate isolates with separate `Request` objects, and both
|
|
14
|
+
* have to drain their own.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The longest message a Turn will carry. */
|
|
18
|
+
export const TURN_TEXT_MAX_CHARACTERS_V1 = 32_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The largest body the send route will read.
|
|
22
|
+
*
|
|
23
|
+
* Generous over the text limit because the same JSON also carries a command
|
|
24
|
+
* id, Skill refs and the retry target; a body past it cannot contain an
|
|
25
|
+
* acceptable `text` no matter how it is shaped.
|
|
26
|
+
*/
|
|
27
|
+
export const TURN_BODY_MAX_BYTES_V1 = TURN_TEXT_MAX_CHARACTERS_V1 * 2;
|
|
28
|
+
|
|
29
|
+
export const TURN_SEND_PATH_V1 = /^\/api\/bots\/[^/]+\/turns$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the composer shows when a send is refused for size.
|
|
33
|
+
*
|
|
34
|
+
* A person wrote too much. That is not a fault and not a server error, so the
|
|
35
|
+
* answer is 413 with a sentence that says what to do about it, rather than a
|
|
36
|
+
* 400 naming a byte budget nobody chose.
|
|
37
|
+
*/
|
|
38
|
+
export const TURN_TOO_LONG_MESSAGE_V1 = `Your message is too long. Keep it under ${TURN_TEXT_MAX_CHARACTERS_V1.toLocaleString("en-US")} characters.`;
|
|
39
|
+
|
|
40
|
+
/** A size refusal, told apart from an unreadable body. */
|
|
41
|
+
export class RequestTooLargeError extends Error {
|
|
42
|
+
override readonly name = "RequestTooLargeError";
|
|
43
|
+
constructor(message = TURN_TOO_LONG_MESSAGE_V1) {
|
|
44
|
+
super(message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isRequestTooLargeV1(error: unknown): boolean {
|
|
49
|
+
return (
|
|
50
|
+
typeof error === "object" &&
|
|
51
|
+
error !== null &&
|
|
52
|
+
"name" in error &&
|
|
53
|
+
error.name === "RequestTooLargeError"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Requests whose body has been handed to a subrequest.
|
|
59
|
+
*
|
|
60
|
+
* `new Request(request, init)` and `fetch(request)` pipe the incoming body
|
|
61
|
+
* onward without ever setting `bodyUsed` on the request we were handed, so
|
|
62
|
+
* `bodyUsed` cannot tell a forwarded body from an unread one. It has to be
|
|
63
|
+
* recorded where the handoff happens.
|
|
64
|
+
*
|
|
65
|
+
* A `WeakSet` because the entry is worth exactly as long as the request is.
|
|
66
|
+
*/
|
|
67
|
+
const forwardedBodies = new WeakSet<Request>();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Record that this request's body now belongs to a subrequest.
|
|
71
|
+
*
|
|
72
|
+
* Call it at every handoff, before awaiting the subrequest. Once the
|
|
73
|
+
* subrequest has answered, the pipe is closed from the far end and touching
|
|
74
|
+
* the near end — a cancel included — raises "Can't read from request stream
|
|
75
|
+
* after response has been sent". That error surfaces on the isolate rather
|
|
76
|
+
* than on the promise, so no `try`/`catch` around the cancel can contain it:
|
|
77
|
+
* it took workerd down mid-suite, every run, on the one POST whose body no
|
|
78
|
+
* route reads.
|
|
79
|
+
*/
|
|
80
|
+
export function forwardingBodyV1<T>(request: Request, forwarded: T): T {
|
|
81
|
+
forwardedBodies.add(request);
|
|
82
|
+
return forwarded;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether this request's body was handed on rather than read here. */
|
|
86
|
+
export function bodyWasForwardedV1(request: Request): boolean {
|
|
87
|
+
return forwardedBodies.has(request);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many bytes a drain will read before it gives up and cancels.
|
|
92
|
+
*
|
|
93
|
+
* Generous: every route that admits a body of its own is bounded far below
|
|
94
|
+
* this, so reaching it means a client is sending something no route wanted.
|
|
95
|
+
* Past it the connection is worth less than the time spent reading it.
|
|
96
|
+
*/
|
|
97
|
+
const DRAIN_BUDGET_BYTES_V1 = 8 * 1024 * 1024;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the body out and throw it away, a chunk at a time.
|
|
101
|
+
*
|
|
102
|
+
* Cancelling is not enough, and this is the whole lesson of the incident.
|
|
103
|
+
* `cancel()` tears the stream down from the reading end; the writing end — the
|
|
104
|
+
* gateway pumping the browser's bytes into the loaded application's isolate —
|
|
105
|
+
* is still holding bytes it has not delivered when the response goes out, and
|
|
106
|
+
* that is what workerd reports as "Can't read from request stream after
|
|
107
|
+
* response has been sent". Reading to `done` is what actually retires the
|
|
108
|
+
* pipe. Verified against the dev stack: with a cancel the error is on every
|
|
109
|
+
* `POST /conversations`; with a read there is none.
|
|
110
|
+
*
|
|
111
|
+
* A reader loop rather than `arrayBuffer()`, so the bytes are dropped as they
|
|
112
|
+
* arrive and a large body is never held whole.
|
|
113
|
+
*/
|
|
114
|
+
async function consumeBodyV1(body: ReadableStream<Uint8Array>): Promise<void> {
|
|
115
|
+
const reader = body.getReader();
|
|
116
|
+
let seen = 0;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const chunk = await reader.read();
|
|
119
|
+
if (chunk.done) return;
|
|
120
|
+
seen += chunk.value?.byteLength ?? 0;
|
|
121
|
+
if (seen > DRAIN_BUDGET_BYTES_V1) {
|
|
122
|
+
await reader.cancel();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Answer, having consumed the request's body.
|
|
130
|
+
*
|
|
131
|
+
* A body already read is left alone by `bodyUsed`; a body handed to a
|
|
132
|
+
* subrequest is left alone by `forwardingBodyV1`, because that subrequest's
|
|
133
|
+
* own wrapper is the one that owes it a read and the stream is no longer this
|
|
134
|
+
* isolate's to touch. Anything else is drained here, and a drain that throws
|
|
135
|
+
* anyway must never become the answer the client sees.
|
|
136
|
+
*/
|
|
137
|
+
export async function drainedAnswerV1(
|
|
138
|
+
request: Request,
|
|
139
|
+
response: Response,
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
const body = request.body;
|
|
142
|
+
if (body && !request.bodyUsed && !bodyWasForwardedV1(request)) {
|
|
143
|
+
try {
|
|
144
|
+
await consumeBodyV1(body);
|
|
145
|
+
} catch {
|
|
146
|
+
// The stream was already gone. The answer stands either way.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return response;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A declared body too large to be worth reading.
|
|
154
|
+
*
|
|
155
|
+
* Content-Length is what a browser's `fetch` always sends for a string body,
|
|
156
|
+
* which is every send the composer makes. A chunked request declaring no
|
|
157
|
+
* length falls through to the route's own decoder, which is bounded too — this
|
|
158
|
+
* guard exists so the common oversized send is never parsed at all.
|
|
159
|
+
*/
|
|
160
|
+
export function turnBodyIsOversizedV1(request: Request, url: URL): boolean {
|
|
161
|
+
if (request.method !== "POST") return false;
|
|
162
|
+
if (!TURN_SEND_PATH_V1.test(url.pathname)) return false;
|
|
163
|
+
const declared = Number(request.headers.get("content-length") ?? "");
|
|
164
|
+
return Number.isFinite(declared) && declared > TURN_BODY_MAX_BYTES_V1;
|
|
165
|
+
}
|