@frockbot/cloudflare 0.0.0 → 0.7.291

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.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. 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
+ }