@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.
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
@@ -0,0 +1,21 @@
1
+ import { type SessionEvent } from "@frockbot/core/contracts";
2
+
3
+ function sameEvent(
4
+ left: SessionEvent,
5
+ right: SessionEvent | undefined,
6
+ ): boolean {
7
+ return right !== undefined && JSON.stringify(left) === JSON.stringify(right);
8
+ }
9
+
10
+ export function appendedSessionEvents(
11
+ previous: readonly SessionEvent[],
12
+ candidate: readonly SessionEvent[],
13
+ ): SessionEvent[] {
14
+ if (
15
+ candidate.length < previous.length ||
16
+ previous.some((event, index) => !sameEvent(event, candidate[index]))
17
+ ) {
18
+ throw new Error("candidate changed durable session history");
19
+ }
20
+ return structuredClone(candidate.slice(previous.length));
21
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The boundary every entry point answers behind.
3
+ *
4
+ * The gateway's `fetch` has had an error boundary for a long time. Nothing else
5
+ * did — and the dev Worker died twice in one evening on a `BotNotFoundError`
6
+ * thrown out of a Durable Object's own `fetch` during a state-channel upgrade,
7
+ * followed by wrangler reporting the network connection lost. An entry point is
8
+ * whatever workerd calls into: `fetch`, a WebSocket callback, `alarm`,
9
+ * `scheduled`, `queue`. Every one of them is a place where a thrown error has
10
+ * no caller left to catch it, so every one of them ends here instead.
11
+ *
12
+ * Two shapes, because the two kinds of entry point owe different things. A
13
+ * request-shaped one owes an answer, and the answer is JSON with a readable
14
+ * reason — a client that receives `Internal Server Error` as plain text can
15
+ * only report a JSON parse failure, which tells the person nothing. A
16
+ * fire-and-forget one owes nothing to anybody, so it records what happened and
17
+ * lets the next firing try again.
18
+ */
19
+
20
+ /**
21
+ * The named failures a Durable Object raises for a Bot that is not there, and
22
+ * the status each is owed.
23
+ *
24
+ * Matched on `name` rather than by class, because the error has crossed an RPC
25
+ * boundary by the time it is read and only its name and message survive — the
26
+ * same convention the gateway's Bot routes already rely on.
27
+ */
28
+ const ENTRY_STATUS_BY_ERROR_NAME_V1 = new Map<string, number>([
29
+ ["BotNotFoundError", 404],
30
+ ["ComputerBotNotFoundError", 404],
31
+ ["BotArchivedError", 409],
32
+ // Gone, not merely absent: the Bot was here, its history has been destroyed,
33
+ // and no retry will bring it back. A caller that still holds the id — a
34
+ // second tab, a queued Turn — is told the difference.
35
+ ["BotDeletedError", 410],
36
+ // The same for the whole account, from the moment its deletion began.
37
+ ["AccountDeletedError", 410],
38
+ // A message named a file this Bot was never given, or no longer holds. The
39
+ // request was well formed; what it names is not there to attach.
40
+ ["UploadNotFoundError", 422],
41
+ ]);
42
+
43
+ function errorName(error: unknown): string | undefined {
44
+ return typeof error === "object" && error !== null && "name" in error
45
+ ? String(error.name)
46
+ : undefined;
47
+ }
48
+
49
+ /** The status an escaped error is owed, defaulting to 500. */
50
+ export function entryFailureStatusV1(error: unknown): number {
51
+ return ENTRY_STATUS_BY_ERROR_NAME_V1.get(errorName(error) ?? "") ?? 500;
52
+ }
53
+
54
+ export function entryFailureResponseV1(
55
+ error: unknown,
56
+ fallback: string,
57
+ ): Response {
58
+ const status = entryFailureStatusV1(error);
59
+ const message =
60
+ error instanceof Error && error.message ? error.message : fallback;
61
+ return Response.json({ error: message }, { status });
62
+ }
63
+
64
+ /**
65
+ * Run a request-shaped entry point, answering rather than throwing.
66
+ *
67
+ * A Bot that is not there is a 404 the caller can act on, not a fault; anything
68
+ * else is a 500 that still carries a reason. Either way the isolate survives to
69
+ * answer the next request, which is the property this exists for.
70
+ */
71
+ export async function answeredEntryV1(
72
+ fallback: string,
73
+ work: () => Promise<Response>,
74
+ ): Promise<Response> {
75
+ try {
76
+ return await work();
77
+ } catch (error) {
78
+ return entryFailureResponseV1(error, fallback);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Run a fire-and-forget entry point — an alarm, a socket callback — recording
84
+ * a failure instead of letting it escape.
85
+ *
86
+ * Swallowing is deliberate and bounded: an alarm is rescheduled by whatever
87
+ * owns it and a socket callback has nobody to answer, so the durable
88
+ * consequence of a failure here is that the next firing tries again. What must
89
+ * not happen is the throw, which in a Durable Object is an uncaught exception
90
+ * and in the dev Worker took the whole process with it.
91
+ */
92
+ export async function loggedEntryV1(
93
+ label: string,
94
+ work: () => void | Promise<void>,
95
+ ): Promise<void> {
96
+ try {
97
+ await work();
98
+ } catch (error) {
99
+ console.error(
100
+ `${label} failed: ${error instanceof Error ? error.message : String(error)}`,
101
+ );
102
+ }
103
+ }
@@ -0,0 +1,338 @@
1
+ import { MODEL_FIRST_BYTE_DEADLINE_MS_V1 } from "@frockbot/core/contracts";
2
+ import {
3
+ FROCK_AI_DEFAULT_AUTO_ROUTE,
4
+ gatewayModelForFrockIdV1,
5
+ } from "@frockbot/providers/frock-ai/catalog";
6
+ import {
7
+ frockAiServedModelFromHeadersV1,
8
+ FrockAiTransportErrorV1,
9
+ type FrockAiChatCompletionV1,
10
+ } from "@frockbot/providers/frock-ai/runtime";
11
+
12
+ export const DEFAULT_FROCK_AI_GATEWAY_ID_V1 = "flock";
13
+
14
+ export interface FrockAiGatewayHostV1 {
15
+ /** `null` when this host took the `AI` binding, which carries no dynamic route. */
16
+ autoRoute: string | null;
17
+ runChatCompletion: FrockAiChatCompletionV1;
18
+ }
19
+
20
+ /**
21
+ * The Gateway request is bounded so a gateway that accepts the connection and
22
+ * then never answers fails the Turn instead of holding it open. The deadline
23
+ * covers *reaching* the gateway — nothing more. The SSE body that follows is
24
+ * bounded by the kernel's own first-byte and idle deadlines, which is where the
25
+ * copy a person reads lives.
26
+ *
27
+ * It was 60s, and it did not stop at the headers: the same signal was handed to
28
+ * `fetch` and left armed, so it guillotined the response body sixty seconds
29
+ * into a tool-calling step. The body's abort surfaced as a raw
30
+ * `TimeoutError: The operation was aborted due to timeout`, which the Agent
31
+ * read as an *uncertain* model outcome and parked the run on — a `POST /turns`
32
+ * answering 500 after 65s with "Couldn't reach the Bot" on screen. A step that
33
+ * carries a large SKILL.md plus the dynamic-tool schemas crosses a minute
34
+ * routinely, so 60s was not a slow gateway, it was the ordinary case.
35
+ *
36
+ * Two minutes now, matching `MODEL_FIRST_BYTE_DEADLINE_MS_V1` so the kernel's
37
+ * deadline — the one with a sentence written for a person — wins the race and
38
+ * this stays the backstop for a transport that never reaches the seam at all.
39
+ * Both are far inside the fifteen-minute Turn deadline.
40
+ */
41
+ export const FROCK_AI_GATEWAY_TIMEOUT_MS_V1 = MODEL_FIRST_BYTE_DEADLINE_MS_V1;
42
+
43
+ export interface FrockAiBillingLimitV1 {
44
+ inputTokens: number;
45
+ outputTokens: number;
46
+ }
47
+
48
+ /**
49
+ * The input tokens one image is held to under a prepaid limit: about what a
50
+ * model that tiles at 28 pixels spends on a 2,048-pixel photo, which is the
51
+ * largest the app sends. Generous on purpose — it only decides whether a
52
+ * request fits its reservation; the charge is what the model reports.
53
+ */
54
+ export const FROCK_AI_IMAGE_INPUT_TOKENS_V1 = 6_000;
55
+
56
+ export interface FrockAiGatewayConfigV1 {
57
+ /**
58
+ * Each hosted model's prepaid bound, keyed by its Frock AI model id, as the
59
+ * deployment's rate table holds them when the request is sent. A request is
60
+ * held to its own model's bound; one whose model has none — a structured
61
+ * Auto request pinned to a model of its own — to the smallest.
62
+ */
63
+ billingLimits?: () => Promise<Record<string, FrockAiBillingLimitV1>>;
64
+ gatewayId?: string;
65
+ autoRoute?: string;
66
+ /**
67
+ * The Cloudflare account owning the Gateway. Present with `token`, requests
68
+ * take the compat HTTP transport; absent, they take the `AI` binding.
69
+ */
70
+ accountId?: string;
71
+ /** The `cf-aig-authorization` bearer for an authenticated Gateway. */
72
+ token?: string;
73
+ /** Injectable for tests; defaults to the global `fetch`. */
74
+ fetch?: typeof fetch;
75
+ /** Deadline for reaching the Gateway. */
76
+ timeoutMs?: number;
77
+ }
78
+
79
+ export function compatChatCompletionsUrlV1(
80
+ accountId: string,
81
+ gatewayId: string,
82
+ ): string {
83
+ return `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat/chat/completions`;
84
+ }
85
+
86
+ function retryAfterMillisecondsV1(value: string | null): number | undefined {
87
+ if (!value) return undefined;
88
+ const seconds = Number(value);
89
+ if (Number.isFinite(seconds) && seconds >= 0)
90
+ return Math.ceil(seconds * 1000);
91
+ const at = Date.parse(value);
92
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
93
+ }
94
+
95
+ /**
96
+ * A rejected request answers with a JSON error body rather than an SSE stream.
97
+ * Left unchecked it decodes as a stream that ends before its terminal marker,
98
+ * which the Agent reads as an *uncertain* outcome and parks the run on — so the
99
+ * status is what tells the two apart.
100
+ */
101
+ async function streamOrThrowV1(
102
+ response: Response,
103
+ served: Parameters<FrockAiChatCompletionV1>[3],
104
+ ): Promise<ReadableStream<Uint8Array>> {
105
+ if (!response.ok) {
106
+ const detail = (await response.text().catch(() => "")).slice(0, 512);
107
+ let reason = detail;
108
+ let code: string | number | undefined;
109
+ try {
110
+ const payload = JSON.parse(detail) as unknown;
111
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
112
+ const error = (payload as Record<string, unknown>).error;
113
+ if (typeof error === "string") reason = error;
114
+ else if (error && typeof error === "object" && !Array.isArray(error)) {
115
+ const envelope = error as Record<string, unknown>;
116
+ if (typeof envelope.message === "string") reason = envelope.message;
117
+ if (
118
+ typeof envelope.code === "string" ||
119
+ typeof envelope.code === "number"
120
+ ) {
121
+ code = envelope.code;
122
+ }
123
+ }
124
+ }
125
+ } catch {
126
+ // The bounded non-JSON provider text is still a useful diagnostic.
127
+ }
128
+ throw new FrockAiTransportErrorV1(
129
+ `AI Gateway rejected the request (${response.status})${
130
+ reason ? `: ${reason}` : ""
131
+ }`,
132
+ response.status,
133
+ retryAfterMillisecondsV1(response.headers.get("retry-after")),
134
+ code,
135
+ );
136
+ }
137
+ if (!response.body) {
138
+ throw new Error("AI Gateway did not return a response stream");
139
+ }
140
+ served?.(frockAiServedModelFromHeadersV1(response.headers));
141
+ return response.body;
142
+ }
143
+
144
+ /** The prepaid bounds by the gateway model each Frock AI model is sent as. */
145
+ function gatewayBillingLimitsV1(
146
+ limits: Record<string, FrockAiBillingLimitV1>,
147
+ autoRoute: string | null,
148
+ ): {
149
+ byGatewayModel: Map<string, FrockAiBillingLimitV1>;
150
+ smallest: FrockAiBillingLimitV1;
151
+ } {
152
+ const byGatewayModel = new Map<string, FrockAiBillingLimitV1>();
153
+ for (const [model, limit] of Object.entries(limits)) {
154
+ let gatewayModel: string;
155
+ try {
156
+ gatewayModel = gatewayModelForFrockIdV1(model, autoRoute);
157
+ } catch {
158
+ // A priced id this deployment cannot send is never requested.
159
+ continue;
160
+ }
161
+ // Two ids sent as one gateway model are held to the tighter bound.
162
+ const shared = byGatewayModel.get(gatewayModel);
163
+ byGatewayModel.set(
164
+ gatewayModel,
165
+ shared
166
+ ? {
167
+ inputTokens: Math.min(shared.inputTokens, limit.inputTokens),
168
+ outputTokens: Math.min(shared.outputTokens, limit.outputTokens),
169
+ }
170
+ : limit,
171
+ );
172
+ }
173
+ const all = Object.values(limits);
174
+ return {
175
+ byGatewayModel,
176
+ smallest: {
177
+ inputTokens: all.length
178
+ ? Math.min(...all.map((limit) => limit.inputTokens))
179
+ : 0,
180
+ outputTokens: all.length
181
+ ? Math.min(...all.map((limit) => limit.outputTokens))
182
+ : 0,
183
+ },
184
+ };
185
+ }
186
+
187
+ /** Keep the generated Cloudflare binding type on the Worker side of the seam. */
188
+ export function createFrockAiGatewayHostV1(
189
+ ai: Pick<Ai, "gateway">,
190
+ config: FrockAiGatewayConfigV1,
191
+ ): FrockAiGatewayHostV1 {
192
+ const gatewayId = config.gatewayId || DEFAULT_FROCK_AI_GATEWAY_ID_V1;
193
+ const { accountId, token } = config;
194
+ // The `AI` binding's `gateway(...).run()` reaches the Gateway's *universal*
195
+ // endpoint, whose request-shape translation rejects a `dynamic/<route>` model
196
+ // before inference runs — cloudflare/ai#617. Concrete `workers-ai/@cf/...`
197
+ // ids survive that translation, so the binding stays the transport wherever
198
+ // no Gateway credentials are configured, which is every local and CI
199
+ // environment that binds a stand-in for `AI`.
200
+ const useCompat = Boolean(accountId && token);
201
+ // Only the compat transport can carry Auto as a route, so on the binding path
202
+ // this host has none and Auto resolves to a concrete model instead.
203
+ const autoRoute = useCompat
204
+ ? config.autoRoute || FROCK_AI_DEFAULT_AUTO_ROUTE
205
+ : null;
206
+ const timeoutMs = config.timeoutMs ?? FROCK_AI_GATEWAY_TIMEOUT_MS_V1;
207
+ const doFetch = config.fetch ?? fetch;
208
+ return {
209
+ autoRoute,
210
+ async runChatCompletion(gatewayModel, body, signal, served) {
211
+ if (config.billingLimits) {
212
+ const billingLimits = gatewayBillingLimitsV1(
213
+ await config.billingLimits(),
214
+ autoRoute,
215
+ );
216
+ const { inputTokens, outputTokens } =
217
+ billingLimits.byGatewayModel.get(gatewayModel) ??
218
+ billingLimits.smallest;
219
+ // A byte bound overcounts text tokens. An image is not its base64:
220
+ // each one counts as a fixed allowance instead, and what it really
221
+ // cost settles from the model's reported usage, whose prompt tokens
222
+ // include the image's.
223
+ let images = 0;
224
+ const encoded = JSON.stringify(body, (key, value: unknown) => {
225
+ if (key !== "image_url") return value;
226
+ images += 1;
227
+ return {};
228
+ });
229
+ if (
230
+ new TextEncoder().encode(encoded).length +
231
+ 1024 +
232
+ images * FROCK_AI_IMAGE_INPUT_TOKENS_V1 >
233
+ inputTokens
234
+ ) {
235
+ throw new FrockAiTransportErrorV1(
236
+ "This request exceeds its prepaid model limit. Use a connected model for this request.",
237
+ 400,
238
+ );
239
+ }
240
+ body = { ...body, max_tokens: outputTokens };
241
+ }
242
+ // The deadline is disarmed the moment the response exists, so what it
243
+ // bounds is reaching the gateway and nothing after it. Left armed it
244
+ // aborted the response *body* mid-stream, which is not a gateway that
245
+ // never answered — it is an answer in progress — and the caller has no
246
+ // way to tell the two apart from the abort alone.
247
+ //
248
+ // The caller's `signal` is still chained in, and it is never disarmed:
249
+ // a Stop must tear the request down whether the headers have arrived or
250
+ // not.
251
+ const deadline = new AbortController();
252
+ const timer = setTimeout(() => {
253
+ deadline.abort(
254
+ new Error(`AI Gateway did not respond within ${timeoutMs}ms`),
255
+ );
256
+ }, timeoutMs);
257
+ const requestSignal = signal
258
+ ? AbortSignal.any([signal, deadline.signal])
259
+ : deadline.signal;
260
+ const timedOut = (error: unknown): never => {
261
+ if (deadline.signal.aborted && !signal?.aborted) {
262
+ throw deadline.signal.reason as Error;
263
+ }
264
+ throw error;
265
+ };
266
+ const reachGatewayV1 = async (): Promise<ReadableStream<Uint8Array>> => {
267
+ if (useCompat) {
268
+ let response: Response;
269
+ try {
270
+ response = await doFetch(
271
+ compatChatCompletionsUrlV1(accountId!, gatewayId),
272
+ {
273
+ method: "POST",
274
+ headers: {
275
+ "content-type": "application/json",
276
+ "cf-aig-authorization": `Bearer ${token!}`,
277
+ // Every answer is billed as the model that produced it; a
278
+ // cached one would be another request's answer.
279
+ "cf-aig-skip-cache": "true",
280
+ },
281
+ body: JSON.stringify({ ...body, model: gatewayModel }),
282
+ signal: requestSignal,
283
+ },
284
+ );
285
+ } catch (error) {
286
+ return timedOut(error);
287
+ }
288
+ return streamOrThrowV1(response, served);
289
+ }
290
+ // The `AI` binding takes no signal, so the deadline is raced against
291
+ // the call rather than cancelling it.
292
+ //
293
+ // The loser of that race is cleaned up rather than left hanging. Once
294
+ // the deadline stopped firing on the success path there was nothing
295
+ // left to settle this promise or drop its listener, so every request
296
+ // that answered normally left one of each attached to a signal that
297
+ // lives as long as the Turn. `abandonRace` is what ends it.
298
+ const abandonRace = new AbortController();
299
+ // An RPC-backed binding hands back a stub; left undisposed, it keeps
300
+ // the Durable Object that asked referenced and unevictable.
301
+ const gateway = ai.gateway(gatewayId) as ReturnType<Ai["gateway"]> &
302
+ Partial<Disposable>;
303
+ let response: Response;
304
+ try {
305
+ response = await Promise.race([
306
+ gateway.run({
307
+ provider: "compat",
308
+ endpoint: "chat/completions",
309
+ headers: { "cf-aig-skip-cache": "true" },
310
+ query: { ...body, model: gatewayModel },
311
+ }),
312
+ new Promise<never>((_resolve, reject) => {
313
+ requestSignal.addEventListener(
314
+ "abort",
315
+ () => reject(requestSignal.reason),
316
+ { once: true, signal: abandonRace.signal },
317
+ );
318
+ }),
319
+ ]);
320
+ } catch (error) {
321
+ return timedOut(error);
322
+ } finally {
323
+ abandonRace.abort();
324
+ gateway[Symbol.dispose]?.();
325
+ }
326
+ return streamOrThrowV1(response, served);
327
+ };
328
+ try {
329
+ return await reachGatewayV1();
330
+ } finally {
331
+ // Disarmed here rather than per-branch: whatever happened, the gateway
332
+ // has either answered or failed, and the body is the stream deadline's
333
+ // to bound from now on.
334
+ clearTimeout(timer);
335
+ }
336
+ },
337
+ };
338
+ }