deepline 0.2.55 → 0.2.56

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 (41) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +14 -0
  2. package/dist/bundling-sources/sdk/src/http.ts +19 -1
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/stream-reconnect.ts +6 -0
  5. package/dist/bundling-sources/sdk/src/types.ts +7 -0
  6. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +146 -12
  7. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +6 -3
  8. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1631 -748
  9. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +20 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +81 -52
  11. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +146 -6
  12. package/dist/bundling-sources/shared_libs/play-runtime/gateway-auth-session.ts +56 -22
  13. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +14 -11
  14. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +21 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +81 -21
  16. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +7 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +3 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +21 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +27 -2
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +49 -10
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +96 -3
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +341 -67
  23. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +10 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +8 -1
  25. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-pg.ts +17 -2
  26. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver.ts +4 -1
  27. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-store-adapter.ts +17 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-writer.ts +16 -2
  29. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-writer.ts +25 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +3 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +2 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +80 -1
  33. package/dist/bundling-sources/shared_libs/security/safe-fetch.ts +9 -0
  34. package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +85 -48
  35. package/dist/cli/index.js +409 -51
  36. package/dist/cli/index.mjs +389 -25
  37. package/dist/index.d.mts +19 -1
  38. package/dist/index.d.ts +19 -1
  39. package/dist/index.js +29 -2
  40. package/dist/index.mjs +29 -2
  41. package/package.json +1 -1
@@ -11,6 +11,20 @@ import {
11
11
  } from './runtime-pg-driver';
12
12
 
13
13
  function wrapPgClient(client: PoolClient): RuntimePoolClient {
14
+ let released = false;
15
+ const releaseClient = (destroy = false) => {
16
+ if (released) return;
17
+ released = true;
18
+ client.release(destroy);
19
+ };
20
+ const destroyTransport = () => {
21
+ // `release(true)` is node-postgres' supported pool-client destruction
22
+ // path. The pool removes the client and Client.end() destroys the socket
23
+ // when a query is active, while keeping pool accounting correct. Runtime
24
+ // callers still run their ordinary `finally { release() }` path after a
25
+ // deadline destroys the transport, so this wrapper owns idempotence.
26
+ releaseClient(true);
27
+ };
14
28
  return {
15
29
  query: <R extends Record<string, unknown> = Record<string, unknown>>(
16
30
  text: string,
@@ -24,9 +38,10 @@ function wrapPgClient(client: PoolClient): RuntimePoolClient {
24
38
  )(text, params).then((result) => ({
25
39
  rows: result.rows,
26
40
  })),
27
- release: () => {
28
- client.release();
41
+ release: (destroy = false) => {
42
+ releaseClient(destroy);
29
43
  },
44
+ destroy: destroyTransport,
30
45
  };
31
46
  }
32
47
 
@@ -13,7 +13,10 @@ export interface RuntimePoolClient {
13
13
  text: string,
14
14
  params?: unknown[],
15
15
  ): Promise<{ rows: R[] }>;
16
- release(): void;
16
+ /** Destroy the physical connection when an active query exceeded its bound. */
17
+ release(destroy?: boolean): void;
18
+ /** Interrupt an active query by destroying the physical transport. */
19
+ destroy?(error?: Error): void;
17
20
  }
18
21
 
19
22
  export interface RuntimePool {
@@ -9,7 +9,10 @@ import type {
9
9
  RuntimeStepReceipt,
10
10
  SkipRuntimeStepReceiptInput,
11
11
  } from './ctx-types';
12
- import { RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES } from './output-size-limits';
12
+ import {
13
+ RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
14
+ RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
15
+ } from './output-size-limits';
13
16
  import {
14
17
  acquireRuntimeReceiptExecutionLockViaAppRuntime,
15
18
  AppRuntimeApiResponseError,
@@ -106,6 +109,12 @@ export type RuntimeReceiptStoreHandlers = Required<
106
109
  Pick<ContextOptions, ReceiptHandlerName>
107
110
  >;
108
111
 
112
+ // Four ambiguous 30-second receipt requests previously consumed the writer's
113
+ // entire two-minute budget. Receipt operations are idempotent and buffered, so
114
+ // keep retry ownership here and allow independent transports enough attempts
115
+ // to recover from a short gateway/network impairment.
116
+ export const RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS = 5 * 60_000;
117
+
109
118
  /**
110
119
  * The runner's single Work Receipt Adapter.
111
120
  *
@@ -116,6 +125,7 @@ export type RuntimeReceiptStoreHandlers = Required<
116
125
  */
117
126
  export class RuntimeReceiptStoreAdapter {
118
127
  readonly handlers: RuntimeReceiptStoreHandlers;
128
+ readonly claimsEstablishExecutionFence = true;
119
129
  readonly #writer: RuntimeReceiptWriter<ReceiptCommand, ReceiptOutput>;
120
130
  #serial = 0;
121
131
 
@@ -125,6 +135,7 @@ export class RuntimeReceiptStoreAdapter {
125
135
  runId: string;
126
136
  maxBatchSize?: number;
127
137
  maxBatchBytes?: number;
138
+ targetBatchBytes?: number;
128
139
  maxFlushMs?: number;
129
140
  maxBufferedBytes?: number;
130
141
  onRetryTelemetry?: (line: string) => void;
@@ -138,8 +149,11 @@ export class RuntimeReceiptStoreAdapter {
138
149
  maxBatchSize: options.maxBatchSize,
139
150
  maxBatchBytes:
140
151
  options.maxBatchBytes ?? RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
152
+ targetBatchBytes:
153
+ options.targetBatchBytes ?? RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
141
154
  maxFlushMs: options.maxFlushMs,
142
155
  maxBufferedBytes: options.maxBufferedBytes,
156
+ maxRetryElapsedMs: RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS,
143
157
  classifyRetryableError: classifyReceiptRetry,
144
158
  onRetryEvent: (event) =>
145
159
  emitReceiptWriterRetryTelemetry({
@@ -309,6 +323,7 @@ function receiptRetryErrorTelemetry(error: unknown): Record<string, unknown> {
309
323
  errorName: error.name,
310
324
  transportAttempts: error.attempts,
311
325
  transportAction: error.action,
326
+ transportAttemptId: error.transportAttemptId,
312
327
  };
313
328
  }
314
329
  if (error instanceof AppRuntimeApiResponseError) {
@@ -343,6 +358,7 @@ function emitReceiptWriterRetryTelemetry(input: {
343
358
  action: receiptCommandTelemetryAction(event.firstInput),
344
359
  batchId: event.batchId,
345
360
  batchSize: event.batchSize,
361
+ batchBytes: event.batchBytes,
346
362
  attempt: event.attempt,
347
363
  elapsedMs: event.elapsedMs,
348
364
  retryAfterMs: event.retryAfterMs,
@@ -27,6 +27,7 @@ export type RuntimeReceiptWriterRetryEvent<Input> = {
27
27
  attempt: number;
28
28
  batchId: number;
29
29
  batchSize: number;
30
+ batchBytes: number;
30
31
  firstInput: Input;
31
32
  elapsedMs: number;
32
33
  retryAfterMs: number;
@@ -103,6 +104,7 @@ export class RuntimeReceiptWriter<Input, Output> {
103
104
  readonly #estimateBytes: (input: Input) => number;
104
105
  readonly #maxBatchSize: number;
105
106
  readonly #maxBatchBytes: number;
107
+ readonly #targetBatchBytes: number;
106
108
  readonly #maxBufferedBytes: number;
107
109
  readonly #maxFlushMs: number;
108
110
  readonly #maxRetryElapsedMs: number;
@@ -135,6 +137,7 @@ export class RuntimeReceiptWriter<Input, Output> {
135
137
  estimateBytes?: (input: Input) => number;
136
138
  maxBatchSize?: number;
137
139
  maxBatchBytes?: number;
140
+ targetBatchBytes?: number;
138
141
  maxBufferedBytes?: number;
139
142
  maxFlushMs?: number;
140
143
  maxRetryElapsedMs?: number;
@@ -154,6 +157,10 @@ export class RuntimeReceiptWriter<Input, Output> {
154
157
  normalizePositiveInteger(options.maxBatchBytes, this.#maxBufferedBytes),
155
158
  this.#maxBufferedBytes,
156
159
  );
160
+ this.#targetBatchBytes = Math.min(
161
+ normalizePositiveInteger(options.targetBatchBytes, this.#maxBatchBytes),
162
+ this.#maxBatchBytes,
163
+ );
157
164
  this.#maxFlushMs = Math.max(0, Math.floor(options.maxFlushMs ?? 5));
158
165
  this.#maxRetryElapsedMs = normalizePositiveInteger(
159
166
  options.maxRetryElapsedMs,
@@ -251,7 +258,7 @@ export class RuntimeReceiptWriter<Input, Output> {
251
258
  this.#bufferedBytes += pending.bytes;
252
259
  if (
253
260
  this.#queued.length >= this.#maxBatchSize ||
254
- this.#queuedBytesAtHead() >= this.#maxBatchBytes
261
+ this.#queuedBytesAtHead() >= this.#targetBatchBytes
255
262
  ) {
256
263
  this.#clearFlushTimer();
257
264
  this.#startPump();
@@ -334,7 +341,10 @@ export class RuntimeReceiptWriter<Input, Output> {
334
341
  while (batch.length < this.#maxBatchSize) {
335
342
  const next = this.#queued[0];
336
343
  if (!next || !Object.is(this.#batchKey(next.input), key)) break;
337
- if (batch.length > 0 && batchBytes + next.bytes > this.#maxBatchBytes) {
344
+ if (
345
+ batch.length > 0 &&
346
+ batchBytes + next.bytes > this.#targetBatchBytes
347
+ ) {
338
348
  break;
339
349
  }
340
350
  this.#queued.shift();
@@ -427,6 +437,10 @@ export class RuntimeReceiptWriter<Input, Output> {
427
437
  attempt: input.attempt,
428
438
  batchId: input.batchId,
429
439
  batchSize: input.batch.length,
440
+ batchBytes: input.batch.reduce(
441
+ (total, pending) => total + pending.bytes,
442
+ 0,
443
+ ),
430
444
  firstInput,
431
445
  elapsedMs: Date.now() - input.startedAt,
432
446
  retryAfterMs: input.retryAfterMs,
@@ -70,6 +70,14 @@ export type RuntimeSheetRowWriterSummary = {
70
70
  terminalRows: number;
71
71
  };
72
72
 
73
+ export type RuntimeSheetRowWriterDiagnostics = {
74
+ activeKind: 'terminal' | 'checkpoint' | null;
75
+ activeRows: number;
76
+ activeStartedAt: number | null;
77
+ blockedTerminalRows: number;
78
+ queuedTerminalRows: number;
79
+ };
80
+
73
81
  export type RuntimeSheetRowSettlement = {
74
82
  /**
75
83
  * Resolves once the bounded live buffer owns this row. Awaiting this promise
@@ -159,6 +167,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
159
167
  readonly #controller = new AbortController();
160
168
 
161
169
  #active: PendingWrite<Row, Update>[] | null = null;
170
+ #activeStartedAt: number | null = null;
162
171
  #bufferedBytes = 0;
163
172
  #liveBytes = 0;
164
173
  #timer: ReturnType<typeof setTimeout> | null = null;
@@ -264,6 +273,20 @@ export class RuntimeSheetRowWriter<Row, Update> {
264
273
  return this.#finishPromise;
265
274
  }
266
275
 
276
+ diagnostics(): RuntimeSheetRowWriterDiagnostics {
277
+ return {
278
+ activeKind: this.#active?.[0]?.kind ?? null,
279
+ activeRows: this.#active?.length ?? 0,
280
+ activeStartedAt: this.#activeStartedAt,
281
+ blockedTerminalRows: this.#blocked.filter(
282
+ (entry) => entry.kind === 'terminal',
283
+ ).length,
284
+ queuedTerminalRows: [...this.#live.values()].filter(
285
+ (entry) => entry.kind === 'terminal',
286
+ ).length,
287
+ };
288
+ }
289
+
267
290
  #enqueueCheckpointUpdate(update: Update): Promise<void> {
268
291
  const key = normalizedKey(this.#checkpointKey(update));
269
292
  if (this.#terminalAcceptedKeys.has(key)) return Promise.resolve();
@@ -403,6 +426,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
403
426
  if (batch.length === 0) return;
404
427
  const batchKind = batch[0]!.kind;
405
428
  this.#active = batch;
429
+ this.#activeStartedAt = Date.now();
406
430
  try {
407
431
  const result = await this.#writeIdenticalBatchWithRetry(batch);
408
432
  const resultCommitted = result ? result.committed : undefined;
@@ -429,6 +453,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
429
453
  );
430
454
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - releasedBytes);
431
455
  this.#active = null;
456
+ this.#activeStartedAt = null;
432
457
  this.#admitBlocked();
433
458
  this.#notifyStateChanged();
434
459
  }
@@ -58,6 +58,9 @@ export type PlaySchedulerSubmitInput = {
58
58
  force?: boolean;
59
59
  /** Explicit cache bypass for completed ctx.tools.execute receipts. */
60
60
  forceToolRefresh?: boolean | null;
61
+ /** Validated per-run ceiling for provider-tool executions and ctx.fetch. */
62
+ maxConcurrentExternalCalls?: number | null;
63
+ maxConcurrentRows?: number | null;
61
64
  inputFile?: {
62
65
  name?: string;
63
66
  path?: string;
@@ -56,6 +56,8 @@ export type PlayExecutionSuspension =
56
56
  exitCodePath: string;
57
57
  /** Exact customer-code completion fence inside the Daytona sandbox. */
58
58
  runtimeCompletedPath?: string;
59
+ /** Bounded watchdog-owned cause marker for crash-only diagnostics. */
60
+ terminationDiagnosticPath?: string;
59
61
  startedAtMs: number;
60
62
  /** Scheduler-owned liveness deadline. The sandbox can renew it only by
61
63
  * persisting `heartbeat_at` through the receipt gateway. */
@@ -1,4 +1,5 @@
1
1
  import { getRuntimeEnv } from '../runtime-env';
2
+ import type { PlayRunnerEvent } from './protocol';
2
3
 
3
4
  export const PLAY_RUNTIME_TEST_FAULT_HEADER = 'x-deepline-test-fault';
4
5
 
@@ -13,7 +14,23 @@ export type RuntimeTestFaultName =
13
14
  * deterministic real-Pg-pool pressure window without changing an action's
14
15
  * SQL or outcome.
15
16
  */
16
- | 'receipt_gateway_hold_ms';
17
+ | 'receipt_gateway_hold_ms'
18
+ /**
19
+ * Synthetic-only bulk-claim fault. The runner forwards this token on the
20
+ * first qualifying physical claim request only. The gateway holds the real
21
+ * checked-out scheduler client for the supplied milliseconds, allowing the
22
+ * claim-operation deadline and connection destruction path to be exercised
23
+ * end to end without making the fault Machine-local.
24
+ */
25
+ | 'receipt_claim_query_hold_once_ms'
26
+ /**
27
+ * Synthetic-only response-boundary fault. The runner fully receives a
28
+ * successful bulk receipt-claim response, then reports the configured first
29
+ * N physical requests as request timeouts. This proves ambiguous-delivery
30
+ * replay without depending on which gateway Machine accepted the request.
31
+ */
32
+ | 'receipt_claim_response_timeout'
33
+ | 'runtime_sheet_page_tail_hold_ms';
17
34
 
18
35
  export type RuntimeTestFaultRegistry = {
19
36
  consume(name: RuntimeTestFaultName): boolean;
@@ -75,6 +92,9 @@ const SUPPORTED_RUNTIME_TEST_FAULTS = new Set<RuntimeTestFaultName>([
75
92
  'worker_receipt_complete_write_fail',
76
93
  'invocation_response_delivery_abort',
77
94
  'receipt_gateway_hold_ms',
95
+ 'receipt_claim_query_hold_once_ms',
96
+ 'receipt_claim_response_timeout',
97
+ 'runtime_sheet_page_tail_hold_ms',
78
98
  ]);
79
99
 
80
100
  const RUNTIME_TEST_POLICY_MS_FIELDS = new Set([
@@ -149,6 +169,65 @@ function parseRuntimeTestFaultHeader(
149
169
  return faults;
150
170
  }
151
171
 
172
+ /** Parse a known fault count without authorizing a request. Unknown/skewed headers return 0. */
173
+ export function recognizedRuntimeTestFaultCount(
174
+ rawHeader: string | null | undefined,
175
+ name: RuntimeTestFaultName,
176
+ ): number {
177
+ const header = rawHeader?.trim();
178
+ if (!header) return 0;
179
+ const parsed = parseRuntimeTestFaultHeader(header);
180
+ if ('error' in parsed) return 0;
181
+ return parsed.get(name) ?? 0;
182
+ }
183
+
184
+ export function runtimeSheetPageTailRowsContainTarget(
185
+ rows: readonly unknown[],
186
+ ): boolean {
187
+ return rows.some((row) => {
188
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return false;
189
+ const record = row as Record<string, unknown>;
190
+ return record.inputIndex === 999 || record.input_index === 999;
191
+ });
192
+ }
193
+
194
+ export function runtimeSheetPageTailWriteMarkerEvent(
195
+ runtimeTestFaultHeader: string | null | undefined,
196
+ rows: readonly Record<string, unknown>[],
197
+ tableNamespace: string,
198
+ dbSessionStrategy: string | null | undefined,
199
+ ): PlayRunnerEvent | null {
200
+ if (
201
+ dbSessionStrategy !== 'gateway_only' ||
202
+ recognizedRuntimeTestFaultCount(
203
+ runtimeTestFaultHeader,
204
+ 'runtime_sheet_page_tail_hold_ms',
205
+ ) <= 0 ||
206
+ !runtimeSheetPageTailRowsContainTarget(rows)
207
+ ) {
208
+ return null;
209
+ }
210
+ return {
211
+ type: 'log',
212
+ at: new Date().toISOString(),
213
+ source: 'play',
214
+ line: `[runtime.sheet-page-tail-write] phase=start table=${tableNamespace} rows=${rows.length}`,
215
+ };
216
+ }
217
+
218
+ export function runtimeSheetPageTailHoldCompletedEvent(
219
+ holdMs: number | null | undefined,
220
+ tableNamespace: string,
221
+ ): PlayRunnerEvent | null {
222
+ if (!Number.isInteger(holdMs) || (holdMs ?? 0) <= 0) return null;
223
+ return {
224
+ type: 'log',
225
+ at: new Date().toISOString(),
226
+ source: 'play',
227
+ line: `[runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
228
+ };
229
+ }
230
+
152
231
  function readPositiveIntegerField(input: {
153
232
  value: unknown;
154
233
  path: string;
@@ -19,6 +19,10 @@ function removeHeader(headers: Headers, name: string) {
19
19
  if (headers.has(name)) headers.delete(name);
20
20
  }
21
21
 
22
+ function cancelResponseBodyBestEffort(response: Response): void {
23
+ void response.body?.cancel().catch(() => undefined);
24
+ }
25
+
22
26
  function requestInitForRedirect(
23
27
  init: RequestInit,
24
28
  from: URL,
@@ -83,6 +87,7 @@ export async function safePublicFetch(
83
87
  return response;
84
88
  }
85
89
  if (redirectMode === 'error') {
90
+ cancelResponseBodyBestEffort(response);
86
91
  throw new Error(
87
92
  `Redirect blocked while fetching ${currentUrl.toString()}.`,
88
93
  );
@@ -96,11 +101,15 @@ export async function safePublicFetch(
96
101
  return response;
97
102
  }
98
103
  if (redirectCount === maxRedirects) {
104
+ cancelResponseBodyBestEffort(response);
99
105
  throw new Error(
100
106
  `Too many redirects while fetching ${currentUrl.toString()}.`,
101
107
  );
102
108
  }
103
109
 
110
+ // A redirect response can stream an unbounded body. The next request must
111
+ // not leave that body/socket flowing in the background.
112
+ cancelResponseBodyBestEffort(response);
104
113
  const nextUrl = resolveRedirectUrl(location, currentUrl);
105
114
  currentInit = requestInitForRedirect(
106
115
  currentInit,
@@ -14,6 +14,8 @@ import {
14
14
  type NodeSafeFetchOptions = {
15
15
  maxRedirects?: number;
16
16
  maxResponseBytes?: number;
17
+ /** Return after validated headers so the caller can enforce a body deadline. */
18
+ streamResponseBody?: boolean;
17
19
  truncateResponseBody?: boolean;
18
20
  sensitiveHeaders?: Iterable<string>;
19
21
  validateUrl?: (url: URL) => void;
@@ -219,62 +221,83 @@ function createRequest(
219
221
  );
220
222
  return;
221
223
  }
222
-
223
- const chunks: Buffer[] = [];
224
- let receivedBytes = 0;
225
- let settled = false;
226
- const resolveResponse = () => {
227
- if (settled) return;
228
- settled = true;
224
+ if (noBodyResponse) {
225
+ response.resume();
229
226
  resolve(
230
- new Response(noBodyResponse ? null : Buffer.concat(chunks), {
227
+ new Response(null, {
231
228
  status,
232
229
  statusText: response.statusMessage,
233
230
  headers: response.headers as HeadersInit,
234
231
  }),
235
232
  );
236
- };
237
- response.on('data', (chunk) => {
238
- if (settled) return;
239
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
240
- const previousReceivedBytes = receivedBytes;
241
- receivedBytes += buffer.byteLength;
242
- if (
243
- maxResponseBytes !== undefined &&
244
- receivedBytes > maxResponseBytes &&
245
- !truncateResponseBody
246
- ) {
247
- response.destroy(
248
- new Error(
249
- `Response body exceeds ${maxResponseBytes} byte limit.`,
250
- ),
251
- );
252
- return;
253
- }
254
- if (!noBodyResponse) {
255
- if (maxResponseBytes !== undefined && truncateResponseBody) {
256
- const remainingBytes = Math.max(
257
- 0,
258
- maxResponseBytes - previousReceivedBytes,
259
- );
260
- if (remainingBytes > 0) {
261
- chunks.push(buffer.subarray(0, remainingBytes));
233
+ return;
234
+ }
235
+
236
+ let receivedBytes = 0;
237
+ let bodySettled = false;
238
+ const body = new ReadableStream<Uint8Array>({
239
+ start(controller) {
240
+ response.on('data', (chunk) => {
241
+ if (bodySettled) return;
242
+ const buffer = Buffer.isBuffer(chunk)
243
+ ? chunk
244
+ : Buffer.from(chunk);
245
+ const previousReceivedBytes = receivedBytes;
246
+ receivedBytes += buffer.byteLength;
247
+ if (
248
+ maxResponseBytes !== undefined &&
249
+ receivedBytes > maxResponseBytes &&
250
+ !truncateResponseBody
251
+ ) {
252
+ bodySettled = true;
253
+ const error = new Error(
254
+ `Response body exceeds ${maxResponseBytes} byte limit.`,
255
+ );
256
+ controller.error(error);
257
+ response.destroy(error);
258
+ return;
262
259
  }
263
- if (receivedBytes >= maxResponseBytes) {
264
- resolveResponse();
265
- response.destroy();
260
+ if (maxResponseBytes !== undefined && truncateResponseBody) {
261
+ const remainingBytes = Math.max(
262
+ 0,
263
+ maxResponseBytes - previousReceivedBytes,
264
+ );
265
+ if (remainingBytes > 0) {
266
+ controller.enqueue(buffer.subarray(0, remainingBytes));
267
+ }
268
+ if (receivedBytes >= maxResponseBytes) {
269
+ bodySettled = true;
270
+ controller.close();
271
+ response.destroy();
272
+ }
273
+ return;
266
274
  }
267
- } else {
268
- chunks.push(buffer);
269
- }
270
- }
275
+ controller.enqueue(buffer);
276
+ });
277
+ response.on('error', (error) => {
278
+ if (bodySettled) return;
279
+ bodySettled = true;
280
+ controller.error(error);
281
+ });
282
+ response.on('end', () => {
283
+ if (bodySettled) return;
284
+ bodySettled = true;
285
+ controller.close();
286
+ });
287
+ },
288
+ cancel(reason) {
289
+ if (bodySettled) return;
290
+ bodySettled = true;
291
+ response.destroy(reason instanceof Error ? reason : undefined);
292
+ },
271
293
  });
272
- response.on('error', (error) => {
273
- if (settled) return;
274
- settled = true;
275
- reject(error);
276
- });
277
- response.on('end', resolveResponse);
294
+ resolve(
295
+ new Response(body, {
296
+ status,
297
+ statusText: response.statusMessage,
298
+ headers: response.headers as HeadersInit,
299
+ }),
300
+ );
278
301
  },
279
302
  );
280
303
 
@@ -359,9 +382,21 @@ export async function safeOutboundFetch(
359
382
  });
360
383
 
361
384
  if (!isRedirectStatus(response.status)) {
362
- return response;
385
+ if (options.streamResponseBody) return response;
386
+ const body = response.body ? await response.arrayBuffer() : null;
387
+ const buffered = new Response(body, {
388
+ status: response.status,
389
+ statusText: response.statusText,
390
+ headers: response.headers,
391
+ });
392
+ Object.defineProperty(buffered, 'url', {
393
+ configurable: true,
394
+ value: response.url,
395
+ });
396
+ return buffered;
363
397
  }
364
398
  if (redirectMode === 'error') {
399
+ void response.body?.cancel().catch(() => undefined);
365
400
  throw new Error(
366
401
  `Redirect blocked while fetching ${currentUrl.toString()}.`,
367
402
  );
@@ -375,11 +410,13 @@ export async function safeOutboundFetch(
375
410
  return response;
376
411
  }
377
412
  if (redirectCount === maxRedirects) {
413
+ void response.body?.cancel().catch(() => undefined);
378
414
  throw new Error(
379
415
  `Too many redirects while fetching ${currentUrl.toString()}.`,
380
416
  );
381
417
  }
382
418
 
419
+ void response.body?.cancel().catch(() => undefined);
383
420
  const nextUrl = resolveRedirectUrl(location, currentUrl);
384
421
  currentInit = initForRedirect(
385
422
  currentInit,