withruntime 0.17.2 → 0.21.0

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 (52) hide show
  1. package/COMPATIBILITY.md +32 -1
  2. package/README.md +19 -0
  3. package/README.src.md +19 -0
  4. package/dist/agents/index.d.ts +11 -0
  5. package/dist/agents/index.js +12 -0
  6. package/dist/agents-cli.d.ts +10 -0
  7. package/dist/agents-cli.js +362 -0
  8. package/dist/apps-cli.d.ts +14 -0
  9. package/dist/apps-cli.js +737 -0
  10. package/dist/builds/index.d.ts +10 -0
  11. package/dist/builds/index.js +11 -0
  12. package/dist/cli-words.js +11 -0
  13. package/dist/cli.d.ts +4 -0
  14. package/dist/cli.js +104 -6
  15. package/dist/client.d.ts +19 -0
  16. package/dist/client.js +20 -0
  17. package/dist/cloudflare/errors.d.ts +23 -0
  18. package/dist/cloudflare/errors.js +16 -0
  19. package/dist/cloudflare/index.d.ts +82 -9
  20. package/dist/cloudflare/index.js +162 -38
  21. package/dist/cloudflare/interpreter.js +14 -5
  22. package/dist/cloudflare/mime.d.ts +1 -0
  23. package/dist/cloudflare/mime.js +66 -0
  24. package/dist/cloudflare/streams.d.ts +8 -0
  25. package/dist/cloudflare/streams.js +30 -0
  26. package/dist/cloudflare/types.d.ts +338 -0
  27. package/dist/cloudflare/types.js +80 -0
  28. package/dist/cloudflare/worker.d.ts +297 -10
  29. package/dist/cloudflare/worker.js +203 -17
  30. package/dist/containers/index.d.ts +10 -0
  31. package/dist/containers/index.js +11 -0
  32. package/dist/daytona/sandbox.d.ts +2 -1
  33. package/dist/daytona/sandbox.js +2 -1
  34. package/dist/identity.js +18 -2
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +1 -0
  37. package/dist/product-names.js +3 -0
  38. package/dist/products/agents.d.ts +272 -0
  39. package/dist/products/agents.js +251 -0
  40. package/dist/products/builds.d.ts +11 -1
  41. package/dist/products/builds.js +7 -0
  42. package/dist/products/containers.d.ts +71 -0
  43. package/dist/products/containers.js +6 -0
  44. package/dist/products/index.d.ts +6 -0
  45. package/dist/products/index.js +7 -0
  46. package/dist/sandbox.d.ts +4 -0
  47. package/dist/transport.d.ts +2 -2
  48. package/dist/transport.js +1 -1
  49. package/dist/tunnel.js +1 -1
  50. package/dist/types.d.ts +11 -0
  51. package/dist/vercel/errors.d.ts +1 -1
  52. package/package.json +16 -1
@@ -3,14 +3,16 @@ import { Runtime } from "../client.js";
3
3
  import { NotFoundError } from "../errors.js";
4
4
  import { timeoutSignal } from "../transport.js";
5
5
  import { quote, client, closeShell, CompatibilityError, refuse, destroy, environment, execOptions, only, pathAt, saveEnvironment, shellCommand, shellRoot, } from "../compat/core.js";
6
- import { BucketUnmountError, InvalidMountConfigError, ProcessExitedBeforeReadyError, ProcessReadyTimeoutError, S3FSMountError, response, } from "./errors.js";
6
+ import { BucketUnmountError, InvalidMountConfigError, ProcessExitedBeforeReadyError, ProcessReadyTimeoutError, S3FSMountError, ValidationFailedError, response, } from "./errors.js";
7
7
  import { CodeInterpreter, hasEnv, RUN_ENV, } from "./interpreter.js";
8
8
  import { sseStream } from "./streams.js";
9
+ import { mimeType } from "./mime.js";
9
10
  import { CHANGES, watchFilters } from "./watch.js";
10
11
  export { CompatibilityError };
11
12
  export * from "./errors.js";
12
13
  export { CodeInterpreter } from "./interpreter.js";
13
- export { collectFile, isExecResult, isProcess, isProcessStatus, parseSSEStream, streamFile, } from "./streams.js";
14
+ export { collectFile, isExecResult, isProcess, isProcessStatus, parseSSEStream, streamFile, asyncIterableToSSEStream, responseToAsyncIterable, } from "./streams.js";
15
+ export * from "./types.js";
14
16
  export class SandboxSecurityError extends Error {
15
17
  code;
16
18
  constructor(message, code) {
@@ -262,7 +264,7 @@ export class Sandbox {
262
264
  return sseStream(async (send) => {
263
265
  send({
264
266
  type: "metadata",
265
- mimeType: isBinary ? "application/octet-stream" : "text/plain",
267
+ mimeType: read.mimeType,
266
268
  size: bytes.length,
267
269
  isBinary,
268
270
  encoding: isBinary ? "base64" : "utf-8",
@@ -800,11 +802,18 @@ export class ExecutionSession {
800
802
  if (options.encoding && !["utf8", "utf-8", "base64"].includes(options.encoding))
801
803
  throw new CompatibilityError("Cloudflare", `file encoding ${options.encoding}`);
802
804
  let bytes;
803
- if (typeof content === "string")
804
- bytes =
805
- options.encoding === "base64"
806
- ? Uint8Array.from(atob(content), (c) => c.charCodeAt(0))
807
- : new TextEncoder().encode(content);
805
+ if (typeof content === "string" && options.encoding === "base64") {
806
+ // Cloudflare's container refuses anything but base64's alphabet.
807
+ if (!/^[A-Za-z0-9+/=]*$/.test(content))
808
+ throw new ValidationFailedError(response("VALIDATION_FAILED", `Invalid base64 content for '${path}': must contain only A-Z, a-z, 0-9, +, /, =`, {
809
+ validationErrors: [
810
+ { field: "content", message: "Invalid base64 characters", code: "INVALID_BASE64" },
811
+ ],
812
+ }, { httpStatus: 400 }));
813
+ bytes = Uint8Array.from(atob(content), (c) => c.charCodeAt(0));
814
+ }
815
+ else if (typeof content === "string")
816
+ bytes = new TextEncoder().encode(content);
808
817
  else {
809
818
  const buffer = await new Response(content).arrayBuffer();
810
819
  bytes = new Uint8Array(buffer);
@@ -813,7 +822,7 @@ export class ExecutionSession {
813
822
  return { success: true, path, bytesWritten: bytes.length, timestamp: new Date().toISOString() };
814
823
  }
815
824
  async readFile(path, options = {}) {
816
- const s = await this.sandbox.native(), bytes = await s.files.read(await this.path(path));
825
+ const s = await this.sandbox.native(), resolved = await this.path(path), bytes = await s.files.read(resolved), type = mimeType(resolved, bytes);
817
826
  if (options.encoding === "none")
818
827
  return {
819
828
  success: true,
@@ -825,7 +834,7 @@ export class ExecutionSession {
825
834
  },
826
835
  }),
827
836
  size: bytes.length,
828
- mimeType: "application/octet-stream",
837
+ mimeType: type,
829
838
  timestamp: new Date().toISOString(),
830
839
  };
831
840
  const text = new TextDecoder().decode(bytes), binary = text.includes("\0") || text.includes("\ufffd"), encoded = options.encoding === "base64" || (options.encoding === undefined && binary);
@@ -839,6 +848,7 @@ export class ExecutionSession {
839
848
  content: encoded ? btoa(raw) : text,
840
849
  encoding: encoded ? "base64" : "utf-8",
841
850
  isBinary: binary,
851
+ mimeType: type,
842
852
  size: bytes.length,
843
853
  timestamp: new Date().toISOString(),
844
854
  };
@@ -888,7 +898,68 @@ export class ExecutionSession {
888
898
  this.options = { ...this.options, env: { ...this.options.env, ...cleanEnv(env) } };
889
899
  await (await this.sandbox.native()).files.write(`/workspace/.runtime-compat/cloudflare/${encodeURIComponent(this.id)}.json`, JSON.stringify(this.options), { mode: 0o600 });
890
900
  }
901
+ // The rest of Cloudflare's ExecutionSession: the sandbox's calls, in this session.
902
+ moveFile(path, newPath) {
903
+ return this.renameFile(path, newPath);
904
+ }
905
+ readFileStream(path) {
906
+ return this.sandbox.readFileStream(path, { sessionId: this.id });
907
+ }
908
+ gitCheckout(repoUrl, options = {}) {
909
+ return this.sandbox.gitCheckout(repoUrl, { ...options, sessionId: this.id });
910
+ }
911
+ watch(path, options = {}) {
912
+ return this.sandbox.watch(path, { ...options, sessionId: this.id });
913
+ }
914
+ checkChanges(path, options = {}) {
915
+ return this.sandbox.checkChanges(path, { ...options, sessionId: this.id });
916
+ }
917
+ listProcesses() {
918
+ return this.sandbox.listProcesses();
919
+ }
920
+ getProcess(id) {
921
+ return this.sandbox.getProcess(id);
922
+ }
923
+ killProcess(id, signal) {
924
+ return this.sandbox.killProcess(id, signal);
925
+ }
926
+ killAllProcesses() {
927
+ return this.sandbox.killAllProcesses();
928
+ }
929
+ cleanupCompletedProcesses() {
930
+ return this.sandbox.cleanupCompletedProcesses();
931
+ }
932
+ getProcessLogs(id) {
933
+ return this.sandbox.getProcessLogs(id);
934
+ }
935
+ streamProcessLogs(id, options) {
936
+ return this.sandbox.streamProcessLogs(id, options);
937
+ }
938
+ createCodeContext(options) {
939
+ return this.sandbox.createCodeContext(options);
940
+ }
941
+ runCode(code, options) {
942
+ return this.sandbox.runCode(code, options);
943
+ }
944
+ runCodeStream(code, options) {
945
+ return this.sandbox.runCodeStream(code, options);
946
+ }
947
+ listCodeContexts() {
948
+ return this.sandbox.listCodeContexts();
949
+ }
950
+ deleteCodeContext(contextId) {
951
+ return this.sandbox.deleteCodeContext(contextId);
952
+ }
953
+ mountBucket(bucket, mountPath, options) {
954
+ return this.sandbox.mountBucket(bucket, mountPath, options);
955
+ }
956
+ unmountBucket(mountPath) {
957
+ return this.sandbox.unmountBucket(mountPath);
958
+ }
891
959
  }
960
+ refuse("Cloudflare", "ExecutionSession", ExecutionSession, {
961
+ terminal: "open a terminal with the native Runtime sandbox's terminal()",
962
+ });
892
963
  export class Process {
893
964
  sandbox;
894
965
  native;
@@ -940,43 +1011,96 @@ export class Process {
940
1011
  });
941
1012
  return { exitCode: r.exitCode ?? -1 };
942
1013
  }
943
- async waitForLog(pattern, timeout = 30000) {
944
- const condition = `log pattern ${String(pattern)}`;
945
- const context = { processId: this.id, command: this.command, condition };
1014
+ /** Cloudflare's errors for a wait, with its messages: a timeout, or the
1015
+ * process ending first. */
1016
+ #timedOut(condition, timeout) {
1017
+ return new ProcessReadyTimeoutError(response("PROCESS_READY_TIMEOUT", `Process did not become ready within ${timeout}ms. Waiting for: ${condition}`, { processId: this.id, command: this.command, condition, timeout }, {
1018
+ httpStatus: 408,
1019
+ suggestion: `Check if your process outputs ${condition}. You can increase the timeout parameter.`,
1020
+ }));
1021
+ }
1022
+ #exited(condition, exitCode) {
1023
+ return new ProcessExitedBeforeReadyError(response("PROCESS_EXITED_BEFORE_READY", `Process exited with code ${exitCode} before becoming ready. Waiting for: ${condition}`, { processId: this.id, command: this.command, condition, exitCode }, { httpStatus: 500, suggestion: "Check process logs with getLogs() for error messages" }));
1024
+ }
1025
+ /** Waits as Cloudflare does: with no limit unless `timeout` is given. */
1026
+ async #within(condition, timeout, wait) {
1027
+ if (timeout === undefined)
1028
+ return wait();
946
1029
  const signal = timeoutSignal(timeout);
947
1030
  try {
948
- return await this.#waitForLog(pattern, signal);
1031
+ return await wait(signal);
949
1032
  }
950
1033
  catch (error) {
951
1034
  if (!signal.aborted)
952
1035
  throw error;
953
- throw new ProcessReadyTimeoutError(response("PROCESS_READY_TIMEOUT", `Process did not become ready within ${timeout}ms`, { ...context, timeout }, { httpStatus: 408, operation: "process.start" }));
1036
+ throw this.#timedOut(condition, timeout);
954
1037
  }
955
1038
  }
956
- async #waitForLog(pattern, signal) {
957
- let output = "";
1039
+ async waitForLog(pattern, timeout) {
1040
+ const condition = typeof pattern === "string" ? `"${pattern}"` : pattern.toString();
1041
+ return this.#within(condition, timeout, (signal) => this.#waitForLog(pattern, condition, signal));
1042
+ }
1043
+ async #waitForLog(pattern, condition, signal) {
1044
+ let stdout = "", stderr = "";
958
1045
  for await (const e of this.native.output({ signal })) {
959
- if (e.type === "stdout" || e.type === "stderr") {
960
- output += e.data;
961
- const match = typeof pattern === "string" ? output.includes(pattern) : output.match(pattern);
962
- if (match)
963
- return {
964
- line: output
965
- .split("\n")
966
- .find((line) => typeof pattern === "string"
967
- ? line.includes(pattern)
968
- : new RegExp(pattern.source, pattern.flags.replace("g", "")).test(line)) ?? output,
969
- ...(typeof pattern === "string" ? {} : { match }),
970
- };
971
- }
972
- else if (e.type === "exit")
973
- throw new ProcessExitedBeforeReadyError(response("PROCESS_EXITED_BEFORE_READY", "Process exited before expected output", {
974
- processId: this.id,
975
- command: this.command,
976
- condition: `log pattern ${String(pattern)}`,
977
- exitCode: e.exitCode ?? -1,
978
- }, { httpStatus: 409, operation: "process.start" }));
1046
+ if (e.type === "stdout")
1047
+ stdout += e.data;
1048
+ else if (e.type === "stderr")
1049
+ stderr += e.data;
1050
+ const found = matchLine(stdout, pattern) ?? matchLine(stderr, pattern);
1051
+ if (found)
1052
+ return found;
1053
+ if (e.type === "exit")
1054
+ throw this.#exited(condition, e.exitCode ?? 1);
979
1055
  }
980
- throw new Error("Process output ended before expected output");
1056
+ throw this.#exited(condition, 0);
1057
+ }
1058
+ /** Resolves once the port answers: an HTTP status in range (200-399 by
1059
+ * default) at `path`, or a TCP connection in `tcp` mode. Throws if the
1060
+ * process ends first, or after `timeout` milliseconds when one is given. */
1061
+ async waitForPort(port, options = {}) {
1062
+ only("Cloudflare waitForPort", options, ["mode", "path", "status", "timeout", "interval"]);
1063
+ const { mode = "http", path = "/", status = { min: 200, max: 399 }, interval = 500 } = options;
1064
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
1065
+ throw new TypeError(`Invalid port number: ${port}`);
1066
+ if (mode !== "http" && mode !== "tcp")
1067
+ throw new TypeError("mode is http or tcp");
1068
+ const condition = mode === "http" ? `port ${port} (HTTP ${path})` : `port ${port} (TCP)`;
1069
+ const min = typeof status === "number" ? status : status.min;
1070
+ const max = typeof status === "number" ? status : status.max;
1071
+ const url = `http://127.0.0.1:${port}${path.startsWith("/") ? path : `/${path}`}`;
1072
+ const probe = mode === "http"
1073
+ ? `code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 ${quote(url)}) && [ "$code" -ge ${min} ] && [ "$code" -le ${max} ]`
1074
+ : `exec 3<>/dev/tcp/127.0.0.1/${port}`;
1075
+ const s = await this.sandbox.native();
1076
+ await this.#within(condition, options.timeout, async (signal) => {
1077
+ for (;;) {
1078
+ await this.native.refresh();
1079
+ if (this.native.info.state !== "running")
1080
+ throw this.#exited(condition, this.native.info.exitCode ?? 1);
1081
+ if ((await s.exec(["bash", "-c", probe], { signal })).exitCode === 0)
1082
+ return;
1083
+ await new Promise((done) => setTimeout(done, interval));
1084
+ signal?.throwIfAborted();
1085
+ }
1086
+ });
981
1087
  }
982
1088
  }
1089
+ /** The first line holding a match, as Cloudflare's waitForLog answers. */
1090
+ function matchLine(text, pattern) {
1091
+ if (typeof pattern === "string") {
1092
+ if (!text.includes(pattern))
1093
+ return null;
1094
+ return { line: text.split("\n").find((line) => line.includes(pattern)) ?? pattern };
1095
+ }
1096
+ const safe = new RegExp(pattern.source, pattern.flags.replace("g", ""));
1097
+ const match = text.match(safe);
1098
+ if (!match)
1099
+ return null;
1100
+ for (const line of text.split("\n")) {
1101
+ const lineMatch = line.match(safe);
1102
+ if (lineMatch)
1103
+ return { line, match: lineMatch };
1104
+ }
1105
+ return { line: match[0], match };
1106
+ }
@@ -106,11 +106,20 @@ export class CodeInterpreter {
106
106
  only("Cloudflare createCodeContext", options, ["language", "cwd", "envVars", "timeout"]);
107
107
  const language = this.#language(options.language);
108
108
  const s = await this.#native();
109
- return context(await s.interpreter.contexts.create({
110
- language,
111
- cwd: options.cwd ?? "/workspace",
112
- env: Object.fromEntries(Object.entries(options.envVars ?? {}).filter((e) => e[1] !== undefined)),
113
- }, options.timeout !== undefined ? { timeoutMs: options.timeout } : {}));
109
+ try {
110
+ return context(await s.interpreter.contexts.create({
111
+ language,
112
+ cwd: options.cwd ?? "/workspace",
113
+ env: Object.fromEntries(Object.entries(options.envVars ?? {}).filter((e) => e[1] !== undefined)),
114
+ }, options.timeout !== undefined ? { timeoutMs: options.timeout } : {}));
115
+ }
116
+ catch (error) {
117
+ // Cloudflare's container has no fixed count; Runtime's does. Say so,
118
+ // and what frees one, in the message: only it survives Worker RPC.
119
+ if (error.code !== "context_limit")
120
+ throw error;
121
+ throw new Error(`${error.message} Cloudflare's container has no fixed limit; delete a context you no longer need with deleteCodeContext(), and list them with listCodeContexts(). runCode without a context uses one per language.`, { cause: error });
122
+ }
114
123
  }
115
124
  async runCode(code, options = {}) {
116
125
  if (hasEnv(options.envVars))
@@ -0,0 +1 @@
1
+ export declare function mimeType(path: string, bytes: Uint8Array): string;
@@ -0,0 +1,66 @@
1
+ /** A file's MIME type as Cloudflare's container reports it: from the
2
+ * extension first, then from the content (`file --mime-type` there; the
3
+ * common signatures and text here), else application/octet-stream. */
4
+ const BY_EXTENSION = {
5
+ png: "image/png",
6
+ jpg: "image/jpeg",
7
+ jpeg: "image/jpeg",
8
+ gif: "image/gif",
9
+ webp: "image/webp",
10
+ svg: "image/svg+xml",
11
+ ico: "image/x-icon",
12
+ bmp: "image/bmp",
13
+ avif: "image/avif",
14
+ pdf: "application/pdf",
15
+ zip: "application/zip",
16
+ gz: "application/gzip",
17
+ tgz: "application/gzip",
18
+ tar: "application/x-tar",
19
+ wasm: "application/wasm",
20
+ json: "application/json",
21
+ xml: "application/xml",
22
+ js: "text/javascript",
23
+ mjs: "text/javascript",
24
+ cjs: "text/javascript",
25
+ css: "text/css",
26
+ html: "text/html",
27
+ htm: "text/html",
28
+ md: "text/markdown",
29
+ txt: "text/plain",
30
+ csv: "text/csv",
31
+ yaml: "text/yaml",
32
+ yml: "text/yaml",
33
+ py: "text/x-python",
34
+ sh: "application/x-sh",
35
+ mp3: "audio/mpeg",
36
+ wav: "audio/wav",
37
+ mp4: "video/mp4",
38
+ webm: "video/webm",
39
+ woff: "font/woff",
40
+ woff2: "font/woff2",
41
+ ttf: "font/ttf",
42
+ };
43
+ const SIGNATURES = [
44
+ [[0x89, 0x50, 0x4e, 0x47], "image/png"],
45
+ [[0xff, 0xd8, 0xff], "image/jpeg"],
46
+ [[0x47, 0x49, 0x46, 0x38], "image/gif"],
47
+ [[0x25, 0x50, 0x44, 0x46], "application/pdf"],
48
+ [[0x50, 0x4b, 0x03, 0x04], "application/zip"],
49
+ [[0x1f, 0x8b], "application/gzip"],
50
+ [[0x00, 0x61, 0x73, 0x6d], "application/wasm"],
51
+ [[0x7f, 0x45, 0x4c, 0x46], "application/x-executable"],
52
+ ];
53
+ export function mimeType(path, bytes) {
54
+ const name = path.split("/").pop() ?? "";
55
+ const dot = name.lastIndexOf(".");
56
+ const known = dot > 0 ? BY_EXTENSION[name.slice(dot + 1).toLowerCase()] : undefined;
57
+ if (known)
58
+ return known;
59
+ for (const [signature, type] of SIGNATURES)
60
+ if (signature.every((byte, i) => bytes[i] === byte))
61
+ return type;
62
+ if (bytes.length === 0)
63
+ return "inode/x-empty";
64
+ const text = new TextDecoder().decode(bytes.subarray(0, 8192));
65
+ return text.includes("\0") || text.includes("�") ? "application/octet-stream" : "text/plain";
66
+ }
@@ -25,6 +25,14 @@ export declare function sseStream<T>(events: (send: (event: T) => void, signal:
25
25
  /** The events of a server-sent-events stream, each `data:` parsed as JSON;
26
26
  * `[DONE]`, blank and unparsable data are skipped, as Cloudflare's do. */
27
27
  export declare function parseSSEStream<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncIterable<T>;
28
+ /** The events of a server-sent-events Response, as parseSSEStream reads them. */
29
+ export declare function responseToAsyncIterable<T>(response: Response, signal?: AbortSignal): AsyncIterable<T>;
30
+ /** Events as a server-sent-events stream, one `data:` line each, then
31
+ * `data: [DONE]`, as Cloudflare's helper writes them. */
32
+ export declare function asyncIterableToSSEStream<T>(events: AsyncIterable<T>, options?: {
33
+ signal?: AbortSignal;
34
+ serialize?: (event: T) => string;
35
+ }): ReadableStream<Uint8Array>;
28
36
  /** A `readFileStream()` stream's chunks: strings for text, bytes for binary.
29
37
  * Returns the file's metadata. */
30
38
  export declare function streamFile(stream: ReadableStream<Uint8Array>): AsyncGenerator<FileChunk, FileMetadata>;
@@ -77,6 +77,36 @@ export async function* parseSSEStream(stream, signal) {
77
77
  reader.releaseLock();
78
78
  }
79
79
  }
80
+ /** The events of a server-sent-events Response, as parseSSEStream reads them. */
81
+ export async function* responseToAsyncIterable(response, signal) {
82
+ if (!response.ok)
83
+ throw new Error(`Response not ok: ${response.status} ${response.statusText}`);
84
+ if (!response.body)
85
+ throw new Error("No response body");
86
+ yield* parseSSEStream(response.body, signal);
87
+ }
88
+ /** Events as a server-sent-events stream, one `data:` line each, then
89
+ * `data: [DONE]`, as Cloudflare's helper writes them. */
90
+ export function asyncIterableToSSEStream(events, options = {}) {
91
+ const encoder = new TextEncoder();
92
+ const serialize = options.serialize ?? ((event) => JSON.stringify(event));
93
+ return new ReadableStream({
94
+ async start(controller) {
95
+ try {
96
+ for await (const event of events) {
97
+ if (options.signal?.aborted)
98
+ throw new Error("Operation was aborted");
99
+ controller.enqueue(encoder.encode(`data: ${serialize(event)}\n\n`));
100
+ }
101
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
102
+ controller.close();
103
+ }
104
+ catch (error) {
105
+ controller.error(error);
106
+ }
107
+ },
108
+ });
109
+ }
80
110
  /** A `readFileStream()` stream's chunks: strings for text, bytes for binary.
81
111
  * Returns the file's metadata. */
82
112
  export async function* streamFile(stream) {