withruntime 0.11.2 → 0.12.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 (164) hide show
  1. package/BLAXEL.md +2 -2
  2. package/COMPATIBILITY.md +74 -9
  3. package/DAYTONA.md +33 -5
  4. package/E2B.md +85 -24
  5. package/README.md +9 -8
  6. package/README.src.md +8 -7
  7. package/VERCEL.md +43 -5
  8. package/compatibility-lock.json +58 -49
  9. package/dist/billing-cli.d.ts +1 -1
  10. package/dist/billing-cli.js +77 -1
  11. package/dist/blaxel/errors.d.ts +14 -0
  12. package/dist/blaxel/errors.js +29 -0
  13. package/dist/blaxel/index.d.ts +2 -2
  14. package/dist/blaxel/index.js +4 -4
  15. package/dist/blaxel/interpreter.d.ts +2 -5
  16. package/dist/blaxel/sandbox.d.ts +14 -5
  17. package/dist/blaxel/sandbox.js +63 -7
  18. package/dist/blaxel/snapshot.d.ts +4 -0
  19. package/dist/blaxel/snapshot.js +4 -0
  20. package/dist/cli/logs.d.ts +21 -0
  21. package/dist/cli/logs.js +188 -0
  22. package/dist/cli/logs.test.d.ts +1 -0
  23. package/dist/cli/logs.test.js +272 -0
  24. package/dist/cli-extras.d.ts +3 -3
  25. package/dist/cli-extras.js +17 -12
  26. package/dist/cli-words.d.ts +10 -1
  27. package/dist/cli-words.js +18 -0
  28. package/dist/cli.d.ts +5 -1
  29. package/dist/cli.js +405 -204
  30. package/dist/client.d.ts +2 -4
  31. package/dist/client.js +27 -11
  32. package/dist/cloudflare/errors.d.ts +143 -0
  33. package/dist/cloudflare/errors.js +167 -0
  34. package/dist/cloudflare/index.d.ts +175 -6
  35. package/dist/cloudflare/index.js +416 -26
  36. package/dist/cloudflare/interpreter.d.ts +119 -0
  37. package/dist/cloudflare/interpreter.js +223 -0
  38. package/dist/cloudflare/streams.d.ts +38 -0
  39. package/dist/cloudflare/streams.js +145 -0
  40. package/dist/cloudflare/watch.d.ts +9 -0
  41. package/dist/cloudflare/watch.js +24 -0
  42. package/dist/codesandbox/index.d.ts +216 -28
  43. package/dist/codesandbox/index.js +478 -78
  44. package/dist/compat/core.d.ts +18 -0
  45. package/dist/compat/core.js +29 -0
  46. package/dist/compat/files.d.ts +1 -0
  47. package/dist/compat/files.js +25 -5
  48. package/dist/daytona/computer-use.d.ts +13 -4
  49. package/dist/daytona/computer-use.js +66 -8
  50. package/dist/daytona/daytona.d.ts +6 -0
  51. package/dist/daytona/daytona.js +30 -3
  52. package/dist/daytona/errors.d.ts +11 -0
  53. package/dist/daytona/errors.js +18 -0
  54. package/dist/daytona/filesystem.js +2 -1
  55. package/dist/daytona/image.d.ts +13 -9
  56. package/dist/daytona/image.js +18 -12
  57. package/dist/daytona/index.d.ts +1 -1
  58. package/dist/daytona/sandbox.d.ts +8 -2
  59. package/dist/daytona/sandbox.js +16 -2
  60. package/dist/domains/index.d.ts +9 -0
  61. package/dist/domains/index.js +10 -0
  62. package/dist/e2b/commands.d.ts +1 -1
  63. package/dist/e2b/commands.js +6 -7
  64. package/dist/e2b/errors.d.ts +17 -0
  65. package/dist/e2b/errors.js +40 -1
  66. package/dist/e2b/index.d.ts +2 -2
  67. package/dist/e2b/index.js +1 -1
  68. package/dist/e2b/sandbox.d.ts +27 -21
  69. package/dist/e2b/sandbox.js +34 -8
  70. package/dist/e2b/unsupported.js +1 -1
  71. package/dist/errors.d.ts +10 -1
  72. package/dist/errors.js +16 -4
  73. package/dist/freestyle/index.d.ts +285 -37
  74. package/dist/freestyle/index.js +656 -144
  75. package/dist/identity.d.ts +3 -0
  76. package/dist/identity.js +27 -0
  77. package/dist/images/index.d.ts +9 -0
  78. package/dist/images/index.js +10 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/ips/index.d.ts +9 -0
  81. package/dist/ips/index.js +10 -0
  82. package/dist/jobs/index.d.ts +9 -0
  83. package/dist/jobs/index.js +10 -0
  84. package/dist/keys.js +2 -0
  85. package/dist/login.js +2 -0
  86. package/dist/machine.d.ts +12 -3
  87. package/dist/machine.js +20 -3
  88. package/dist/mcp.js +1 -1
  89. package/dist/modal/index.d.ts +339 -61
  90. package/dist/modal/index.js +781 -121
  91. package/dist/network-cli.d.ts +3 -1
  92. package/dist/network-cli.js +78 -36
  93. package/dist/networks/index.d.ts +11 -0
  94. package/dist/networks/index.js +10 -0
  95. package/dist/ports/index.d.ts +9 -0
  96. package/dist/ports/index.js +10 -0
  97. package/dist/product-names.d.ts +52 -0
  98. package/dist/product-names.js +164 -0
  99. package/dist/product-renames.d.ts +103 -0
  100. package/dist/product-renames.js +189 -0
  101. package/dist/products/apps.d.ts +176 -0
  102. package/dist/products/apps.js +165 -0
  103. package/dist/products/billing.d.ts +41 -0
  104. package/dist/products/billing.js +13 -0
  105. package/dist/products/browser.d.ts +10 -0
  106. package/dist/products/browsers.d.ts +71 -0
  107. package/dist/products/browsers.js +102 -0
  108. package/dist/products/builds.d.ts +126 -0
  109. package/dist/products/builds.js +221 -0
  110. package/dist/products/index.d.ts +3 -16
  111. package/dist/products/index.js +3 -14
  112. package/dist/products/jobs.d.ts +17 -0
  113. package/dist/products/jobs.js +27 -10
  114. package/dist/products/logs.d.ts +245 -0
  115. package/dist/products/logs.js +274 -0
  116. package/dist/products/logs.test.d.ts +1 -0
  117. package/dist/products/logs.test.js +343 -0
  118. package/dist/products/network-products.d.ts +38 -7
  119. package/dist/products/network-products.js +31 -18
  120. package/dist/products/observability.d.ts +3 -2
  121. package/dist/products/private-network.js +1 -1
  122. package/dist/products/queues.d.ts +149 -0
  123. package/dist/products/queues.js +213 -0
  124. package/dist/products/referrals.d.ts +21 -7
  125. package/dist/products/referrals.js +3 -3
  126. package/dist/products/secrets.d.ts +7 -3
  127. package/dist/products/secrets.js +39 -18
  128. package/dist/products/signed-requests.d.ts +21 -0
  129. package/dist/products/signed-requests.js +11 -0
  130. package/dist/products/streams.d.ts +73 -0
  131. package/dist/products/streams.js +17 -0
  132. package/dist/products/switching.d.ts +35 -7
  133. package/dist/products/switching.js +4 -3
  134. package/dist/products/volumes.d.ts +3 -0
  135. package/dist/record-cli.d.ts +23 -0
  136. package/dist/record-cli.js +308 -0
  137. package/dist/renamed-products.d.ts +38 -0
  138. package/dist/renamed-products.js +34 -0
  139. package/dist/runloop/index.d.ts +9 -2
  140. package/dist/runloop/index.js +29 -8
  141. package/dist/runloop/sdk.d.ts +131 -0
  142. package/dist/runloop/sdk.js +167 -0
  143. package/dist/sandbox.d.ts +3 -2
  144. package/dist/sandbox.js +14 -9
  145. package/dist/sandboxes/index.d.ts +11 -0
  146. package/dist/sandboxes/index.js +12 -0
  147. package/dist/snapshots/index.d.ts +9 -0
  148. package/dist/snapshots/index.js +11 -0
  149. package/dist/snapshots.d.ts +7 -4
  150. package/dist/sprites/index.d.ts +343 -31
  151. package/dist/sprites/index.js +866 -84
  152. package/dist/ssh.d.ts +1 -1
  153. package/dist/ssh.js +11 -11
  154. package/dist/terminal.d.ts +24 -0
  155. package/dist/terminal.js +32 -0
  156. package/dist/transport.d.ts +15 -1
  157. package/dist/transport.js +56 -3
  158. package/dist/tunnel.js +1 -1
  159. package/dist/types.d.ts +63 -0
  160. package/dist/vercel/sandbox.js +5 -3
  161. package/dist/vercel/snapshot.js +10 -2
  162. package/dist/volumes/index.d.ts +9 -0
  163. package/dist/volumes/index.js +10 -0
  164. package/package.json +48 -2
@@ -1,12 +1,99 @@
1
- import { all, client, CompatibilityError, create, destroy, execOptions, only, region, } from "../compat/core.js";
1
+ import { ConflictError as NativeConflictError, InvalidRequestError, NotFoundError as NativeNotFoundError, RateLimitError, RuntimeError, ServiceUnavailableError, } from "../errors.js";
2
+ import { all, client, CompatibilityError, create, destroy, execOptions, only, refuse, region, } from "../compat/core.js";
2
3
  import { GuestFiles } from "../compat/files.js";
3
4
  export { CompatibilityError };
5
+ // Errors ----------------------------------------------------------------------
6
+ export class TimeoutError extends Error {
7
+ }
8
+ export class RemoteError extends Error {
9
+ }
10
+ export class ExecutionError extends Error {
11
+ }
12
+ export class InternalFailure extends Error {
13
+ }
4
14
  export class NotFoundError extends Error {
5
15
  }
6
- export class TimeoutError extends Error {
16
+ export class AlreadyExistsError extends Error {
17
+ }
18
+ export class ConflictError extends Error {
19
+ }
20
+ export class InvalidError extends Error {
21
+ }
22
+ export class ResourceExhaustedError extends Error {
7
23
  }
8
24
  export class SandboxTimeoutError extends TimeoutError {
9
25
  }
26
+ export class SnapshotCreationError extends Error {
27
+ }
28
+ export class ClientClosedError extends Error {
29
+ }
30
+ export class SandboxFilesystemError extends Error {
31
+ }
32
+ export class SandboxFilesystemDirectoryNotEmptyError extends SandboxFilesystemError {
33
+ }
34
+ export class SandboxFilesystemFileTooLargeError extends SandboxFilesystemError {
35
+ }
36
+ export class SandboxFilesystemIsADirectoryError extends SandboxFilesystemError {
37
+ }
38
+ export class SandboxFilesystemNotADirectoryError extends SandboxFilesystemError {
39
+ }
40
+ export class SandboxFilesystemNotFoundError extends SandboxFilesystemError {
41
+ }
42
+ export class SandboxFilesystemPathAlreadyExistsError extends SandboxFilesystemError {
43
+ }
44
+ export class SandboxFilesystemPermissionError extends SandboxFilesystemError {
45
+ }
46
+ /** A Runtime failure as the Modal error a caller catches. */
47
+ function modalError(error) {
48
+ if (!(error instanceof RuntimeError))
49
+ return error;
50
+ if (error.code === "name_taken")
51
+ return new AlreadyExistsError(error.message);
52
+ if (error instanceof NativeNotFoundError)
53
+ return new NotFoundError(error.message);
54
+ if (error instanceof NativeConflictError)
55
+ return new ConflictError(error.message);
56
+ if (error instanceof InvalidRequestError)
57
+ return new InvalidError(error.message);
58
+ if (error instanceof RateLimitError || error.code === "quota_exceeded")
59
+ return new ResourceExhaustedError(error.message);
60
+ if (error instanceof ServiceUnavailableError || error.status >= 500)
61
+ return new InternalFailure(error.message);
62
+ return error;
63
+ }
64
+ async function modal(run) {
65
+ try {
66
+ return await run();
67
+ }
68
+ catch (error) {
69
+ throw modalError(error);
70
+ }
71
+ }
72
+ const FILE_ERRORS = {
73
+ file_not_found: SandboxFilesystemNotFoundError,
74
+ path_not_found: SandboxFilesystemNotFoundError,
75
+ is_a_directory: SandboxFilesystemIsADirectoryError,
76
+ not_a_directory: SandboxFilesystemNotADirectoryError,
77
+ directory_not_empty: SandboxFilesystemDirectoryNotEmptyError,
78
+ already_exists: SandboxFilesystemPathAlreadyExistsError,
79
+ permission_denied: SandboxFilesystemPermissionError,
80
+ file_too_large: SandboxFilesystemFileTooLargeError,
81
+ too_large: SandboxFilesystemFileTooLargeError,
82
+ };
83
+ async function fileCall(path, run) {
84
+ try {
85
+ return await run();
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof RuntimeError))
89
+ throw error;
90
+ const Mapped = FILE_ERRORS[error.code] ?? (error.status === 404 ? SandboxFilesystemNotFoundError : null);
91
+ if (!Mapped)
92
+ throw error;
93
+ throw new Mapped(`${path}: ${error.message}`);
94
+ }
95
+ }
96
+ // Apps, secrets, volumes, bucket mounts -----------------------------------------
10
97
  export class App {
11
98
  appId;
12
99
  name;
@@ -17,72 +104,225 @@ export class App {
17
104
  this.environmentName = environmentName;
18
105
  }
19
106
  }
107
+ export class AppService {
108
+ client;
109
+ constructor(client) {
110
+ this.client = client;
111
+ }
112
+ async fromName(name, params = {}) {
113
+ this.client.open();
114
+ only("Modal app", params, ["createIfMissing", "environment"]);
115
+ if (params.environment)
116
+ throw new CompatibilityError("Modal", "named Modal environments");
117
+ if (!params.createIfMissing &&
118
+ !(await all(this.client.runtime, "modal")).some((s) => s.info.labels["modal.app"] === name))
119
+ throw new NotFoundError(`App ${name} has no Runtime sandboxes; pass createIfMissing: true.`);
120
+ return new App(name, name);
121
+ }
122
+ }
123
+ /** Values the sandbox receives as environment variables, from the caller. */
20
124
  export class Secret {
21
125
  values;
22
- constructor(values) {
126
+ name;
127
+ secretId;
128
+ constructor(values, name) {
23
129
  this.values = values;
130
+ this.name = name;
131
+ this.secretId = `st-${crypto.randomUUID()}`;
24
132
  }
25
133
  }
26
- export class ModalClient {
27
- runtime;
28
- apps;
29
- images;
30
- sandboxes;
31
- secrets = { fromObject: (values) => new Secret(values) };
32
- constructor(options = {}) {
33
- only("Modal client", options, [
34
- "apiKey",
35
- "baseUrl",
36
- "fetch",
37
- "client",
38
- "timeoutMs",
39
- "maxRetries",
40
- "tokenId",
41
- "tokenSecret",
42
- "environment",
43
- ]);
44
- this.runtime = client(options);
45
- const runtime = this.runtime;
46
- this.apps = {
47
- fromName: async (name, params = {}) => {
48
- only("Modal app", params, ["createIfMissing", "environment"]);
49
- if (params.environment || options.environment)
50
- throw new CompatibilityError("Modal", "named Modal environments");
51
- if (!params.createIfMissing &&
52
- !(await all(runtime, "modal")).some((s) => s.info.labels["modal.app"] === name))
53
- throw new NotFoundError(`App ${name} has no Runtime sandboxes; pass createIfMissing: true.`);
54
- return new App(name, name);
55
- },
56
- };
57
- this.images = {
58
- fromRegistry: (tag, secret) => {
59
- if (secret)
60
- throw new CompatibilityError("Modal", "registry Secret; configure Runtime image registry credentials");
61
- return new Image(runtime, tag);
62
- },
63
- fromId: async (id) => new Image(runtime, undefined, (await runtime.images.get(id)).id),
64
- fromName: async (name) => new Image(runtime, undefined, (await runtime.images.resolve(name)).id),
65
- delete: (id) => runtime.images.delete(id),
134
+ const RUNTIME_SECRETS = "Runtime secrets are used without being seen: runtime.secrets.set({ value, hosts }); or pass values with secrets.fromObject";
135
+ export class SecretService {
136
+ client;
137
+ constructor(client) {
138
+ this.client = client;
139
+ }
140
+ async fromObject(entries, params = {}) {
141
+ this.client.open();
142
+ only("Modal secret", params, ["environment"]);
143
+ if (params.environment)
144
+ throw new CompatibilityError("Modal", "named Modal environments");
145
+ for (const [key, value] of Object.entries(entries))
146
+ if (typeof value !== "string")
147
+ throw new InvalidError(`Secret value for ${key} must be a string`);
148
+ return new Secret({ ...entries });
149
+ }
150
+ }
151
+ refuse("Modal", "SecretService", SecretService, {
152
+ fromName: RUNTIME_SECRETS,
153
+ delete: RUNTIME_SECRETS,
154
+ });
155
+ const FIXED_SIZE = "Runtime volumes have a fixed size: create one with runtime.volumes.create({ name, sizeMiB })";
156
+ export class Volume {
157
+ volumeId;
158
+ name;
159
+ readOnly;
160
+ constructor(volumeId, name, readOnly = false) {
161
+ this.volumeId = volumeId;
162
+ this.name = name;
163
+ this.readOnly = readOnly;
164
+ }
165
+ withMountOptions(params = {}) {
166
+ only("Modal volume mount", params, ["readOnly", "subPath"]);
167
+ if (params.subPath)
168
+ throw new CompatibilityError("Modal", "subPath; mount the whole volume");
169
+ return new Volume(this.volumeId, this.name, params.readOnly ?? this.readOnly);
170
+ }
171
+ closeEphemeral() {
172
+ throw new InvalidError(`Volume ${this.name ?? this.volumeId} is not ephemeral`);
173
+ }
174
+ }
175
+ export class VolumeService {
176
+ client;
177
+ constructor(client) {
178
+ this.client = client;
179
+ }
180
+ async find(name) {
181
+ const page = await modal(() => this.client.runtime.volumes.list({ name, limit: 100 }));
182
+ return page.data.find((v) => v.name === name && v.state !== "deleted");
183
+ }
184
+ async fromName(name, params = {}) {
185
+ this.client.open();
186
+ only("Modal volume", params, ["createIfMissing", "environment"]);
187
+ if (params.environment)
188
+ throw new CompatibilityError("Modal", "named Modal environments");
189
+ const found = await this.find(name);
190
+ if (found)
191
+ return new Volume(found.id, name);
192
+ if (params.createIfMissing)
193
+ throw new CompatibilityError("Modal", `createIfMissing; ${FIXED_SIZE}`);
194
+ throw new NotFoundError(`Volume ${name} not found`);
195
+ }
196
+ async delete(name, params = {}) {
197
+ this.client.open();
198
+ only("Modal volume delete", params, ["allowMissing", "environment"]);
199
+ if (params.environment)
200
+ throw new CompatibilityError("Modal", "named Modal environments");
201
+ const found = await this.find(name);
202
+ if (!found) {
203
+ if (params.allowMissing)
204
+ return;
205
+ throw new NotFoundError(`Volume ${name} not found`);
206
+ }
207
+ await modal(() => this.client.runtime.volumes.delete(found.id));
208
+ }
209
+ }
210
+ refuse("Modal", "VolumeService", VolumeService, { ephemeral: FIXED_SIZE });
211
+ const BUCKET_KEYS = "store the bucket key with runtime.secrets.set and mount it with the native sandbox's mounts.add";
212
+ export class CloudBucketMount {
213
+ bucketName;
214
+ readOnly;
215
+ bucketEndpointUrl;
216
+ keyPrefix;
217
+ /** Refused at creation: Runtime mounts public buckets here, keyed ones natively. */
218
+ secret;
219
+ requesterPays = false;
220
+ oidcAuthRoleArn;
221
+ constructor(bucketName, readOnly = false, bucketEndpointUrl, keyPrefix) {
222
+ this.bucketName = bucketName;
223
+ this.readOnly = readOnly;
224
+ this.bucketEndpointUrl = bucketEndpointUrl;
225
+ this.keyPrefix = keyPrefix;
226
+ }
227
+ /** The native mount for `path`: the provider read from the endpoint's host. */
228
+ native(path) {
229
+ const host = this.bucketEndpointUrl ? new URL(this.bucketEndpointUrl).hostname : undefined;
230
+ const r2 = host?.match(/^([0-9a-f]+)\.r2\.cloudflarestorage\.com$/);
231
+ return {
232
+ provider: r2
233
+ ? "r2"
234
+ : host === "storage.googleapis.com"
235
+ ? "gcs"
236
+ : "s3",
237
+ bucket: this.bucketName,
238
+ path,
239
+ ...(r2 ? { accountId: r2[1] } : host ? { endpoint: host } : {}),
240
+ ...(this.keyPrefix ? { prefix: this.keyPrefix } : {}),
241
+ readOnly: this.readOnly,
66
242
  };
67
- this.sandboxes = new SandboxService(runtime);
68
243
  }
69
- close() {
70
- /* Transport has no owned background connection to close. */
244
+ }
245
+ refuse("Modal", "CloudBucketMount", CloudBucketMount, { toProto: "pass it in cloudBucketMounts" }, { sync: true });
246
+ export class CloudBucketMountService {
247
+ create(bucketName, params = {}) {
248
+ only("Modal bucket mount", params, [
249
+ "secret",
250
+ "readOnly",
251
+ "requesterPays",
252
+ "bucketEndpointUrl",
253
+ "keyPrefix",
254
+ "oidcAuthRoleArn",
255
+ ]);
256
+ if (params.secret)
257
+ throw new CompatibilityError("Modal", `bucket secret; ${BUCKET_KEYS}`);
258
+ if (params.requesterPays || params.oidcAuthRoleArn)
259
+ throw new CompatibilityError("Modal", `requesterPays and oidcAuthRoleArn; ${BUCKET_KEYS}`);
260
+ if (params.bucketEndpointUrl)
261
+ new URL(params.bucketEndpointUrl);
262
+ return new CloudBucketMount(bucketName, params.readOnly ?? false, params.bucketEndpointUrl, params.keyPrefix);
71
263
  }
72
264
  }
265
+ export class Proxy {
266
+ }
267
+ export class ProxyService {
268
+ }
269
+ const STATIC_IPS = "Runtime static egress IPs: runtime.ips";
270
+ refuse("Modal", "Proxy", Proxy, { proxyId: STATIC_IPS }, { sync: true });
271
+ refuse("Modal", "ProxyService", ProxyService, { fromName: STATIC_IPS });
272
+ export class ExperimentalOutboundPolicy {
273
+ }
274
+ refuse("Modal", "ExperimentalOutboundPolicy", ExperimentalOutboundPolicy, {
275
+ withHeaderReplacement: "Runtime secrets set the header on the way out: runtime.secrets.set({ value, hosts, header })",
276
+ }, { sync: true });
277
+ export class Probe {
278
+ spec;
279
+ constructor(spec) {
280
+ this.spec = spec;
281
+ }
282
+ static interval(name, intervalMs) {
283
+ if (!Number.isInteger(intervalMs))
284
+ throw new InvalidError(`${name}() expects an integer \`intervalMs\``);
285
+ if (intervalMs <= 0)
286
+ throw new InvalidError(`${name}() expects \`intervalMs\` > 0, got ${String(intervalMs)}`);
287
+ }
288
+ static withTcp(port, params = { intervalMs: 100 }) {
289
+ if (!Number.isInteger(port))
290
+ throw new InvalidError("Probe.withTcp() expects an integer `port`");
291
+ if (port <= 0 || port > 65535)
292
+ throw new InvalidError(`Probe.withTcp() expects \`port\` in [1, 65535], got ${port}`);
293
+ Probe.interval("Probe.withTcp", params.intervalMs);
294
+ return new Probe({ tcpPort: port, intervalMs: params.intervalMs });
295
+ }
296
+ static withExec(argv, params = { intervalMs: 100 }) {
297
+ if (!Array.isArray(argv) || argv.length === 0)
298
+ throw new InvalidError("Probe.withExec() requires at least one argument");
299
+ if (!argv.every((arg) => typeof arg === "string"))
300
+ throw new InvalidError("Probe.withExec() expects all arguments to be strings");
301
+ Probe.interval("Probe.withExec", params.intervalMs);
302
+ return new Probe({ execArgv: [...argv], intervalMs: params.intervalMs });
303
+ }
304
+ }
305
+ refuse("Modal", "Probe", Probe, { toProto: "pass it as readinessProbe" }, { sync: true });
306
+ // Images ------------------------------------------------------------------------
73
307
  export class Image {
74
308
  runtime;
75
309
  tag;
76
310
  commands;
77
311
  env;
78
312
  cache;
313
+ snapshot;
79
314
  built;
80
- constructor(runtime, tag, id, commands = [], env = {}, cache = true) {
315
+ constructor(
316
+ /** Absent only for an Image that is already built. */
317
+ runtime, tag, id, commands = [], env = {}, cache = true,
318
+ /** The id is a native disk snapshot (snapshotFilesystem), not an image. */
319
+ snapshot = false) {
81
320
  this.runtime = runtime;
82
321
  this.tag = tag;
83
322
  this.commands = commands;
84
323
  this.env = env;
85
324
  this.cache = cache;
325
+ this.snapshot = snapshot;
86
326
  this.built = id;
87
327
  }
88
328
  get imageId() {
@@ -98,47 +338,199 @@ export class Image {
98
338
  }
99
339
  async build(_app) {
100
340
  if (!this.built) {
101
- const built = this.commands.length
102
- ? await this.runtime.images.build({
341
+ const built = await modal(() => this.commands.length
342
+ ? this.runtime.images.build({
103
343
  dockerfile: [`FROM ${this.tag}`, ...this.commands].join("\n"),
104
344
  env: this.env,
105
345
  cache: this.cache,
106
346
  })
107
- : await this.runtime.images.build({ image: this.tag, env: this.env, cache: this.cache });
347
+ : this.runtime.images.build({ image: this.tag, env: this.env, cache: this.cache }));
108
348
  this.built = built.id;
109
349
  }
110
350
  return this;
111
351
  }
112
352
  }
353
+ refuse("Modal", "Image", Image, {
354
+ publish: "build it under a name: runtime.images.build({ name }), then images.fromName",
355
+ });
356
+ const REGISTRY_LOGIN = "store the registry login with runtime.images.registries, then use images.fromRegistry";
357
+ export class ImageService {
358
+ client;
359
+ constructor(client) {
360
+ this.client = client;
361
+ }
362
+ fromRegistry(tag, secret) {
363
+ this.client.open();
364
+ if (secret)
365
+ throw new CompatibilityError("Modal", `registry Secret; ${REGISTRY_LOGIN}`);
366
+ return new Image(this.client.runtime, tag);
367
+ }
368
+ async fromId(imageId) {
369
+ this.client.open();
370
+ return new Image(this.client.runtime, undefined, (await modal(() => this.client.runtime.images.get(imageId))).id);
371
+ }
372
+ async fromName(name, params = {}) {
373
+ this.client.open();
374
+ only("Modal image name", params, ["environment"]);
375
+ if (params.environment)
376
+ throw new CompatibilityError("Modal", "named Modal environments");
377
+ return new Image(this.client.runtime, undefined, (await modal(() => this.client.runtime.images.resolve(name))).id);
378
+ }
379
+ async delete(imageId, _params = {}) {
380
+ this.client.open();
381
+ await modal(() => this.client.runtime.images.delete(imageId));
382
+ }
383
+ }
384
+ refuse("Modal", "ImageService", ImageService, { fromAwsEcr: REGISTRY_LOGIN, fromGcpArtifactRegistry: REGISTRY_LOGIN }, { sync: true });
385
+ // Snapshots ---------------------------------------------------------------------
386
+ export class SandboxSnapshot {
387
+ snapshotId;
388
+ constructor(snapshotId) {
389
+ this.snapshotId = snapshotId;
390
+ }
391
+ }
392
+ export class SandboxSnapshotService {
393
+ client;
394
+ constructor(client) {
395
+ this.client = client;
396
+ }
397
+ async fromId(snapshotId) {
398
+ this.client.open();
399
+ const snapshot = await modal(() => this.client.runtime.snapshots.get(snapshotId));
400
+ if (snapshot.state === "deleted" || snapshot.state === "deleting")
401
+ throw new NotFoundError(`Sandbox snapshot ${snapshotId} not found`);
402
+ return new SandboxSnapshot(snapshot.id);
403
+ }
404
+ }
405
+ export class ModalClient {
406
+ runtime;
407
+ apps;
408
+ images;
409
+ sandboxes;
410
+ secrets;
411
+ volumes;
412
+ cloudBucketMounts = new CloudBucketMountService();
413
+ sandboxSnapshots;
414
+ proxies = new ProxyService();
415
+ #closed = false;
416
+ constructor(options = {}) {
417
+ if (options.endpoint !== undefined ||
418
+ options.oauthRefreshToken !== undefined ||
419
+ options.oauthClientId !== undefined ||
420
+ options.oauthClientSecret !== undefined ||
421
+ options.oauthJwtKey !== undefined ||
422
+ options.maxThrottleWaitSecs !== undefined ||
423
+ options.logger !== undefined ||
424
+ options.logLevel !== undefined ||
425
+ options.grpcMiddleware !== undefined ||
426
+ options.cpClient !== undefined)
427
+ throw new CompatibilityError("Modal", "Modal endpoints, OAuth, gRPC and logger options; set RUNTIME_API_KEY or pass apiKey");
428
+ only("Modal client", options, [
429
+ "apiKey",
430
+ "baseUrl",
431
+ "fetch",
432
+ "client",
433
+ "timeoutMs",
434
+ "maxRetries",
435
+ "tokenId",
436
+ "tokenSecret",
437
+ "environment",
438
+ ]);
439
+ if (options.environment)
440
+ throw new CompatibilityError("Modal", "named Modal environments");
441
+ this.runtime = client(options);
442
+ this.apps = new AppService(this);
443
+ this.images = new ImageService(this);
444
+ this.sandboxes = new SandboxService(this.runtime, this);
445
+ this.secrets = new SecretService(this);
446
+ this.volumes = new VolumeService(this);
447
+ this.sandboxSnapshots = new SandboxSnapshotService(this);
448
+ }
449
+ /** Throws once close() was called, as Modal's client does. */
450
+ open() {
451
+ if (this.#closed)
452
+ throw new ClientClosedError("The Modal client was closed");
453
+ }
454
+ environmentName(environment) {
455
+ if (environment)
456
+ throw new CompatibilityError("Modal", "named Modal environments");
457
+ return "";
458
+ }
459
+ /** The Modal SDK version whose interface this adapter implements. */
460
+ version() {
461
+ return "0.11.0";
462
+ }
463
+ close() {
464
+ // The transport owns no background connection; later calls fail as Modal's do.
465
+ this.#closed = true;
466
+ }
467
+ }
468
+ const NO_MODAL_SERVER = "Runtime has no Modal control plane; use the native client at modal.runtime";
469
+ refuse("Modal", "ModalClient", ModalClient, {
470
+ cpClient: NO_MODAL_SERVER,
471
+ ipClient: NO_MODAL_SERVER,
472
+ profile: NO_MODAL_SERVER,
473
+ logger: NO_MODAL_SERVER,
474
+ }, { sync: true });
475
+ refuse("Modal", "ModalClient", ModalClient, {
476
+ getImageBuilderVersion: "Runtime images record theirs: (await runtime.images.get(id)).build.builderVersion",
477
+ });
478
+ /** Modal's outbound allowlists as Runtime network rules, never wider than
479
+ * Modal's: a missing list or a wildcard entry allows everything, an empty
480
+ * list blocks all egress. Runtime's rules are one list a connection matches
481
+ * any entry of, so a domain list and a CIDR list that both narrow are
482
+ * refused rather than joined into a wider rule. */
483
+ function egress(domains, cidrs) {
484
+ if (domains?.length === 0 || cidrs?.length === 0)
485
+ return { internet: false };
486
+ const anyDomain = !domains || domains.includes("*");
487
+ const anyCidr = !cidrs || cidrs.some((c) => c === "0.0.0.0/0" || c === "::/0");
488
+ if (anyDomain && anyCidr)
489
+ return { internet: true };
490
+ if (!anyDomain && !anyCidr)
491
+ throw new CompatibilityError("Modal", "a domain allowlist and a CIDR allowlist at once; pass one list of domains and CIDR ranges to sandbox.native.network.set({ internet: true, allow })");
492
+ return { internet: true, allow: anyDomain ? cidrs : domains };
493
+ }
494
+ const ENVIRONMENTS = "named Modal environments";
495
+ const CREATE_OPTIONS = [
496
+ "name",
497
+ "cpu",
498
+ "cpuLimit",
499
+ "memoryMiB",
500
+ "memoryLimitMiB",
501
+ "runtime",
502
+ "timeoutMs",
503
+ "workdir",
504
+ "command",
505
+ "env",
506
+ "encryptedPorts",
507
+ "blockNetwork",
508
+ "outboundDomainAllowlist",
509
+ "outboundCidrAllowlist",
510
+ "tags",
511
+ "regions",
512
+ "gpu",
513
+ "pty",
514
+ "secrets",
515
+ "volumes",
516
+ "cloudBucketMounts",
517
+ "readinessProbe",
518
+ "experimentalEnableSnapshot",
519
+ ];
113
520
  export class SandboxService {
114
521
  runtime;
115
- constructor(runtime) {
522
+ client;
523
+ constructor(runtime, client) {
116
524
  this.runtime = runtime;
525
+ this.client = client;
117
526
  }
118
527
  async create(app, image, params = {}) {
119
- only("Modal sandbox", params, [
120
- "name",
121
- "cpu",
122
- "cpuLimit",
123
- "memoryMiB",
124
- "memoryLimitMiB",
125
- "timeoutMs",
126
- "workdir",
127
- "command",
128
- "env",
129
- "encryptedPorts",
130
- "blockNetwork",
131
- "outboundDomainAllowlist",
132
- "outboundCidrAllowlist",
133
- "tags",
134
- "regions",
135
- "gpu",
136
- "pty",
137
- "secrets",
138
- "experimentalEnableSnapshot",
139
- ]);
528
+ this.client?.open();
529
+ only("Modal sandbox", params, CREATE_OPTIONS);
140
530
  if (params.gpu)
141
531
  throw new CompatibilityError("Modal", "GPU allocation");
532
+ if (params.runtime === "gvisor")
533
+ throw new CompatibilityError("Modal", 'gVisor; Runtime sandboxes are Firecracker VMs (runtime: "vm")');
142
534
  if (params.cpuLimit !== undefined && params.cpuLimit !== params.cpu)
143
535
  throw new CompatibilityError("Modal", "separate CPU reservation and hard limit");
144
536
  if (params.memoryLimitMiB !== undefined && params.memoryLimitMiB !== params.memoryMiB)
@@ -147,23 +539,49 @@ export class SandboxService {
147
539
  throw new CompatibilityError("Modal", "multiple region placement");
148
540
  if (params.cpu !== undefined && !Number.isInteger(params.cpu))
149
541
  throw new CompatibilityError("Modal", "fractional CPU reservations");
542
+ if (image.snapshot && (params.cpu !== undefined || params.memoryMiB !== undefined))
543
+ throw new CompatibilityError("Modal", "cpu or memoryMiB with a snapshot image; it keeps the snapshot's shape, resize it after");
150
544
  if (params.blockNetwork && (params.outboundDomainAllowlist || params.outboundCidrAllowlist))
151
545
  throw new TypeError("blockNetwork cannot be combined with allowlists");
546
+ if (params.runtime !== undefined && params.runtime !== "vm")
547
+ throw new InvalidError(`Unknown sandbox runtime ${String(params.runtime)}`);
548
+ const network = params.blockNetwork
549
+ ? { internet: false }
550
+ : egress(params.outboundDomainAllowlist, params.outboundCidrAllowlist);
152
551
  const placement = region("Modal", params.regions?.[0]);
153
552
  const built = await image.build(app);
553
+ const volumes = Object.entries(params.volumes ?? {}).map(([path, v]) => {
554
+ if (!(v instanceof Volume))
555
+ throw new InvalidError(`volumes[${path}] must be a Volume`);
556
+ return {
557
+ volumeId: v.volumeId,
558
+ path,
559
+ mode: v.readOnly ? "snapshot" : "rw",
560
+ };
561
+ });
562
+ const mounts = Object.entries(params.cloudBucketMounts ?? {}).map(([path, m]) => {
563
+ if (!(m instanceof CloudBucketMount))
564
+ throw new InvalidError(`cloudBucketMounts[${path}] must be a CloudBucketMount`);
565
+ return m.native(path);
566
+ });
154
567
  let primary;
155
- const s = await create(this.runtime, "modal", {
568
+ const input = {
156
569
  name: params.name ? `${app.name ?? app.appId}-${params.name}` : undefined,
157
570
  labels: {
158
571
  "modal.app": app.name ?? app.appId,
159
572
  ...(params.name ? { "modal.name": params.name } : {}),
160
573
  "modal.tags": JSON.stringify(params.tags ?? {}),
574
+ ...(params.readinessProbe
575
+ ? { "modal.probe": JSON.stringify(params.readinessProbe.spec) }
576
+ : {}),
577
+ ...(volumes.some((v) => v.mode === "snapshot") ? { "modal.readonly": "1" } : {}),
161
578
  },
162
- image: built.imageId,
163
- cpu: "reserved",
164
- vcpu: params.cpu,
165
- memoryMiB: params.memoryMiB,
579
+ ...(image.snapshot ? { snapshot: built.imageId } : { image: built.imageId }),
580
+ ...(image.snapshot
581
+ ? {}
582
+ : { cpu: "reserved", vcpu: params.cpu, memoryMiB: params.memoryMiB }),
166
583
  region: placement,
584
+ ...(volumes.length ? { volumes } : {}),
167
585
  // A timeout is the customer's limit, and the sandbox never pauses
168
586
  // for idleness before it. None is no time limit (0300): it runs while
169
587
  // it works and pauses when idle.
@@ -171,14 +589,11 @@ export class SandboxService {
171
589
  ? {}
172
590
  : { timeoutSeconds: Math.ceil(params.timeoutMs / 1000), idlePauseSeconds: 0 }),
173
591
  onLeaseEnd: "stop",
174
- network: {
175
- internet: !params.blockNetwork,
176
- allow: [
177
- ...(params.outboundDomainAllowlist ?? []),
178
- ...(params.outboundCidrAllowlist ?? []),
179
- ],
180
- },
181
- }, Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}), async (s) => {
592
+ network,
593
+ };
594
+ const s = await modal(() => create(this.runtime, "modal", input, Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}), async (s) => {
595
+ for (const mount of mounts)
596
+ await s.mounts.add(mount);
182
597
  primary = await s.spawn(params.command ?? ["sleep", "infinity"], {
183
598
  ...(await execOptions(s, { cwd: params.workdir })),
184
599
  stdin: "pipe",
@@ -187,19 +602,28 @@ export class SandboxService {
187
602
  await s.update({ labels: { ...s.info.labels, "modal.entrypoint": primary.id } });
188
603
  for (const port of params.encryptedPorts ?? [])
189
604
  await s.previews.create(port, { visibility: "public" });
190
- });
191
- const sandbox = new Sandbox(s, new ContainerProcess(primary, "text"));
605
+ }));
606
+ const sandbox = new Sandbox(s, new ContainerProcess(primary, "text"), this.client);
192
607
  sandbox.monitorEntrypoint();
193
608
  return sandbox;
194
609
  }
610
+ async experimentalCreate(app, image, params = {}) {
611
+ only("Modal sandbox", params, CREATE_OPTIONS);
612
+ return this.create(app, image, params);
613
+ }
195
614
  async fromId(id) {
196
- const s = await this.runtime.sandboxes.get(id);
615
+ this.client?.open();
616
+ const s = await modal(() => this.runtime.sandboxes.get(id));
197
617
  const p = s.info.labels["modal.entrypoint"];
198
618
  if (!p)
199
619
  throw new NotFoundError("Sandbox is not a Modal-compatible sandbox");
200
- return new Sandbox(s, new ContainerProcess(await s.processes.get(p), "text"));
620
+ return new Sandbox(s, new ContainerProcess(await modal(() => s.processes.get(p)), "text"), this.client);
201
621
  }
202
- async fromName(appName, name) {
622
+ async fromName(appName, name, params = {}) {
623
+ this.client?.open();
624
+ only("Modal sandbox name", params, ["environment"]);
625
+ if (params.environment)
626
+ throw new CompatibilityError("Modal", ENVIRONMENTS);
203
627
  const s = (await all(this.runtime, "modal")).find((s) => s.info.labels["modal.app"] === appName &&
204
628
  s.info.labels["modal.name"] === name &&
205
629
  s.state === "running");
@@ -207,14 +631,55 @@ export class SandboxService {
207
631
  throw new NotFoundError(`Sandbox ${name} not found`);
208
632
  return this.fromId(s.id);
209
633
  }
634
+ experimentalFromName(appName, name, params = {}) {
635
+ return this.fromName(appName, name, params);
636
+ }
637
+ /** A new sandbox continuing a memory snapshot: its files, memory and
638
+ * running entrypoint, under the snapshot's app and tags. */
639
+ async experimentalFromSnapshot(snapshot, params = {}) {
640
+ this.client?.open();
641
+ only("Modal snapshot restore", params, ["name"]);
642
+ const saved = await modal(() => this.runtime.snapshots.get(snapshot.snapshotId));
643
+ const labels = (saved.labels ?? {});
644
+ const entrypoint = labels["modal.entrypoint"];
645
+ if (!entrypoint || saved.mode === "disk")
646
+ throw new InvalidError(`Snapshot ${snapshot.snapshotId} was not taken with sandbox.experimentalSnapshot()`);
647
+ const app = labels["modal.app"];
648
+ const s = await modal(() => create(this.runtime, "modal", {
649
+ snapshot: saved.id,
650
+ ...(params.name ? { name: `${app}-${params.name}` } : {}),
651
+ labels: {
652
+ ...Object.fromEntries(Object.entries(labels).filter(([k]) => k.startsWith("modal."))),
653
+ ...(params.name ? { "modal.name": params.name } : {}),
654
+ },
655
+ onLeaseEnd: "stop",
656
+ }));
657
+ let process;
658
+ try {
659
+ process = await s.processes.get(entrypoint);
660
+ }
661
+ catch (error) {
662
+ await destroy(s);
663
+ throw modalError(error);
664
+ }
665
+ const sandbox = new Sandbox(s, new ContainerProcess(process, "text"), this.client);
666
+ sandbox.monitorEntrypoint();
667
+ return sandbox;
668
+ }
210
669
  async *list(params = {}) {
211
- only("Modal list", params, ["appId", "tags"]);
670
+ this.client?.open();
671
+ only("Modal list", params, ["appId", "tags", "environment"]);
672
+ if (params.environment)
673
+ throw new CompatibilityError("Modal", ENVIRONMENTS);
212
674
  for (const s of await all(this.runtime, "modal"))
213
675
  if (s.state === "running" &&
214
676
  (!params.appId || s.info.labels["modal.app"] === params.appId) &&
215
677
  Object.entries(params.tags ?? {}).every(([k, v]) => JSON.parse(s.info.labels["modal.tags"] ?? "{}")[k] === v))
216
678
  yield await this.fromId(s.id);
217
679
  }
680
+ experimentalList(params = {}) {
681
+ return this.list(params);
682
+ }
218
683
  }
219
684
  export class ModalReadStream extends ReadableStream {
220
685
  constructor(process, channel, mode, ignore = false) {
@@ -328,13 +793,67 @@ export class ContainerProcess {
328
793
  return p.state === "running" ? null : p.exitCode;
329
794
  }
330
795
  }
796
+ /** Host and port of a sandbox port's public HTTPS address. Runtime previews
797
+ * are HTTPS only, so there is no unencrypted address. */
798
+ export class Tunnel {
799
+ host;
800
+ port;
801
+ unencryptedHost;
802
+ unencryptedPort;
803
+ constructor(host, port, unencryptedHost, unencryptedPort) {
804
+ this.host = host;
805
+ this.port = port;
806
+ this.unencryptedHost = unencryptedHost;
807
+ this.unencryptedPort = unencryptedPort;
808
+ }
809
+ get url() {
810
+ return this.port === 443 ? `https://${this.host}` : `https://${this.host}:${this.port}`;
811
+ }
812
+ get tlsSocket() {
813
+ return [this.host, this.port];
814
+ }
815
+ get tcpSocket() {
816
+ if (!this.unencryptedHost || this.unencryptedPort === undefined)
817
+ throw new InvalidError("This tunnel is not configured for unencrypted TCP.");
818
+ return [this.unencryptedHost, this.unencryptedPort];
819
+ }
820
+ }
821
+ const NEW_SANDBOX_FROM_SNAPSHOT = "take sandbox.snapshotFilesystem() and start a new sandbox from that image";
822
+ export class SandboxLogsManager {
823
+ }
824
+ refuse("Modal", "SandboxLogsManager", SandboxLogsManager, {
825
+ fetch: "read sandbox.stdout and sandbox.stderr",
826
+ tail: "read sandbox.stdout and sandbox.stderr",
827
+ }, { sync: true });
828
+ const SECOND_SANDBOX = "start a second Runtime sandbox and join both to a private network";
829
+ export class SidecarService {
830
+ }
831
+ refuse("Modal", "SidecarService", SidecarService, { create: SECOND_SANDBOX, get: SECOND_SANDBOX });
832
+ refuse("Modal", "SidecarService", SidecarService, { list: SECOND_SANDBOX }, { sync: true });
833
+ export class SidecarContainer {
834
+ }
835
+ refuse("Modal", "SidecarContainer", SidecarContainer, { containerId: SECOND_SANDBOX, containerName: SECOND_SANDBOX, filesystem: SECOND_SANDBOX }, { sync: true });
836
+ refuse("Modal", "SidecarContainer", SidecarContainer, Object.fromEntries([
837
+ "exec",
838
+ "mountImage",
839
+ "poll",
840
+ "reloadVolumes",
841
+ "snapshotDirectory",
842
+ "snapshotFilesystem",
843
+ "terminate",
844
+ "unmountImage",
845
+ "wait",
846
+ ].map((m) => [m, SECOND_SANDBOX])));
331
847
  export class Sandbox {
332
848
  native;
333
849
  primary;
850
+ client;
334
851
  filesystem;
335
852
  stdin;
336
853
  stdout;
337
854
  stderr;
855
+ logs = new SandboxLogsManager();
856
+ experimentalSidecars = new SidecarService();
338
857
  detached = false;
339
858
  lifecycle;
340
859
  monitorEntrypoint() {
@@ -344,9 +863,10 @@ export class Sandbox {
344
863
  });
345
864
  this.lifecycle.catch(() => undefined);
346
865
  }
347
- constructor(native, primary) {
866
+ constructor(native, primary, client) {
348
867
  this.native = native;
349
868
  this.primary = primary;
869
+ this.client = client;
350
870
  this.filesystem = new SandboxFilesystem(native);
351
871
  this.stdin = primary.stdin;
352
872
  this.stdout = primary.stdout;
@@ -355,9 +875,13 @@ export class Sandbox {
355
875
  get sandboxId() {
356
876
  return this.native.id;
357
877
  }
358
- async exec(command, params = {}) {
878
+ attached() {
879
+ this.client?.open();
359
880
  if (this.detached)
360
- throw new Error("Sandbox is detached");
881
+ throw new ClientClosedError("Sandbox is detached");
882
+ }
883
+ async exec(command, params = {}) {
884
+ this.attached();
361
885
  only("Modal exec", params, [
362
886
  "mode",
363
887
  "stdout",
@@ -381,8 +905,7 @@ export class Sandbox {
381
905
  return new ContainerProcess(p, params.mode ?? "text", params);
382
906
  }
383
907
  async setTags(tags) {
384
- if (this.detached)
385
- throw new Error("Sandbox is detached");
908
+ this.attached();
386
909
  await this.native.update({
387
910
  labels: {
388
911
  ...Object.fromEntries(Object.entries(this.native.info.labels).filter(([k]) => k.startsWith("modal.") || k === "compat.provider")),
@@ -391,11 +914,21 @@ export class Sandbox {
391
914
  });
392
915
  }
393
916
  async getTags() {
394
- if (this.detached)
395
- throw new Error("Sandbox is detached");
917
+ this.attached();
396
918
  await this.native.refresh();
397
919
  return JSON.parse(this.native.info.labels["modal.tags"] ?? "{}");
398
920
  }
921
+ /** Names the sandbox so sandboxes.fromName(app, name) finds it. */
922
+ async experimentalSetName(name) {
923
+ this.attached();
924
+ if (!name)
925
+ throw new InvalidError("Sandbox name must not be empty");
926
+ const app = this.native.info.labels["modal.app"];
927
+ await modal(() => this.native.update({
928
+ name: `${app}-${name}`,
929
+ labels: { ...this.native.info.labels, "modal.name": name },
930
+ }));
931
+ }
399
932
  async terminate(params = {}) {
400
933
  await destroy(this.native);
401
934
  return params.wait ? this.primary.wait() : undefined;
@@ -413,23 +946,99 @@ export class Sandbox {
413
946
  poll() {
414
947
  return this.primary._poll();
415
948
  }
416
- async tunnels() {
417
- return Object.fromEntries((await this.native.previews.list()).map((p) => [
418
- p.port,
419
- { host: new URL(p.url).hostname, port: 443, url: p.urlWithToken ?? p.url },
420
- ]));
949
+ /** Runs the sandbox's readinessProbe until it passes. */
950
+ async waitUntilReady(timeoutMs = 300_000) {
951
+ this.attached();
952
+ if (timeoutMs <= 0)
953
+ throw new InvalidError(`timeoutMs must be positive, got ${timeoutMs}`);
954
+ const raw = this.native.info.labels["modal.probe"];
955
+ if (!raw)
956
+ throw new InvalidError("Sandbox has no readinessProbe");
957
+ const probe = JSON.parse(raw);
958
+ const argv = probe.execArgv ?? ["bash", "-c", `exec 3<>/dev/tcp/127.0.0.1/${probe.tcpPort}`];
959
+ const until = performance.now() + timeoutMs;
960
+ while (true) {
961
+ const left = Math.ceil(until - performance.now());
962
+ if (left <= 0)
963
+ throw new TimeoutError("Timeout expired");
964
+ const result = await this.native.exec(argv, { timeoutMs: left });
965
+ if (result.exitCode === 0)
966
+ return;
967
+ await new Promise((resolve) => setTimeout(resolve, Math.min(probe.intervalMs, left)));
968
+ }
969
+ }
970
+ async tunnels(timeoutMs = 50_000) {
971
+ this.attached();
972
+ const tunnels = {};
973
+ for (const p of await this.native.previews.list({ timeoutMs }))
974
+ tunnels[p.port] = new Tunnel(new URL(p.url).hostname, 443);
975
+ return tunnels;
421
976
  }
422
977
  async updateNetworkPolicy(params) {
423
- await this.native.network.set({
424
- internet: true,
425
- allow: [...params.outboundCidrAllowlist, ...params.outboundDomainAllowlist],
426
- });
978
+ this.attached();
979
+ only("Modal network policy", params, ["outboundCidrAllowlist", "outboundDomainAllowlist"]);
980
+ if (!params.outboundCidrAllowlist || !params.outboundDomainAllowlist)
981
+ throw new InvalidError("Both outboundCidrAllowlist and outboundDomainAllowlist must be provided");
982
+ await this.native.network.set(egress(params.outboundDomainAllowlist, params.outboundCidrAllowlist));
427
983
  }
984
+ /** Runtime volumes are block devices with one writer: a read-write mount
985
+ * always shows the latest writes. A read-only mount is a copy taken at
986
+ * start, which cannot reload. */
987
+ async reloadVolumes(params = {}) {
988
+ this.attached();
989
+ only("Modal reload volumes", params, ["timeoutMs"]);
990
+ if (this.native.info.labels["modal.readonly"])
991
+ throw new CompatibilityError("Modal", "reloading a read-only volume; it is a copy taken at start");
992
+ }
993
+ /** The whole sandbox, memory and running processes included. */
428
994
  async experimentalSnapshot() {
429
- const snapshot = await this.native.snapshot();
430
- return { sandboxSnapshotId: snapshot.id };
995
+ this.attached();
996
+ const snapshot = await modal(() => this.native.snapshot({
997
+ labels: Object.fromEntries(Object.entries(this.native.info.labels).filter(([k]) => k.startsWith("modal."))),
998
+ })).catch((error) => {
999
+ throw error instanceof Error && !(error instanceof NotFoundError)
1000
+ ? new SnapshotCreationError(error.message)
1001
+ : error;
1002
+ });
1003
+ return new SandboxSnapshot(snapshot.id);
1004
+ }
1005
+ /** The sandbox's files as an Image new sandboxes start from. `ttlMs` is
1006
+ * kept in whole days. */
1007
+ async snapshotFilesystem(params = {}) {
1008
+ this.attached();
1009
+ only("Modal filesystem snapshot", params, ["timeoutMs", "ttlMs"]);
1010
+ const day = 86_400_000;
1011
+ if (params.ttlMs === null ||
1012
+ (params.ttlMs !== undefined && (!Number.isInteger(params.ttlMs / day) || params.ttlMs <= 0)))
1013
+ throw new CompatibilityError("Modal", "ttlMs that is null or not whole days; Runtime keeps snapshots for a number of days");
1014
+ const snapshot = await this.native
1015
+ .snapshot({
1016
+ mode: "disk",
1017
+ ...(params.ttlMs === undefined ? {} : { retentionDays: params.ttlMs / day }),
1018
+ ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
1019
+ })
1020
+ .catch((error) => {
1021
+ throw error instanceof RuntimeError && error.code === "timeout"
1022
+ ? new TimeoutError(error.message)
1023
+ : new SnapshotCreationError(error instanceof Error ? error.message : String(error));
1024
+ });
1025
+ return new Image(undefined, undefined, snapshot.id, [], {}, true, true);
431
1026
  }
432
1027
  }
1028
+ refuse("Modal", "Sandbox", Sandbox, {
1029
+ createConnectToken: "a private preview: sandbox.native.previews.create(port), sending its token as x-runtime-preview-token",
1030
+ experimentalGetExitSnapshot: NEW_SANDBOX_FROM_SNAPSHOT,
1031
+ snapshotDirectory: NEW_SANDBOX_FROM_SNAPSHOT,
1032
+ mountImage: NEW_SANDBOX_FROM_SNAPSHOT,
1033
+ unmountImage: NEW_SANDBOX_FROM_SNAPSHOT,
1034
+ experimentalUpdateOutboundPolicy: "updateNetworkPolicy for allowlists; runtime.secrets.set({ value, hosts, header }) for header replacement",
1035
+ });
1036
+ const WATCHED = {
1037
+ Access: ["access"],
1038
+ Create: ["create"],
1039
+ Modify: ["write", "chmod", "rename"],
1040
+ Remove: ["remove"],
1041
+ };
433
1042
  export class SandboxFilesystem {
434
1043
  native;
435
1044
  files;
@@ -438,36 +1047,35 @@ export class SandboxFilesystem {
438
1047
  this.files = new GuestFiles(async () => native, "/");
439
1048
  }
440
1049
  readBytes(path) {
441
- return this.files.readFile(path);
1050
+ return fileCall(path, () => this.files.readFile(path));
442
1051
  }
443
1052
  readText(path) {
444
- return this.files.readTextFile(path);
1053
+ return fileCall(path, () => this.files.readTextFile(path));
445
1054
  }
446
1055
  writeBytes(content, path) {
447
- return this.files.writeFile(path, content instanceof Uint8Array ? content : new Uint8Array(content));
1056
+ return fileCall(path, () => this.files.writeFile(path, content instanceof Uint8Array ? content : new Uint8Array(content)));
448
1057
  }
449
1058
  writeText(content, path) {
450
- return this.files.writeTextFile(path, content);
1059
+ return fileCall(path, () => this.files.writeTextFile(path, content));
451
1060
  }
452
1061
  makeDirectory(path, options = {}) {
453
- return this.files.mkdir(path, options.createParents ?? true);
1062
+ return fileCall(path, () => this.files.mkdir(path, options.createParents ?? true));
454
1063
  }
455
1064
  remove(path, options = {}) {
456
- return this.files.remove(path, options.recursive);
1065
+ return fileCall(path, () => this.files.remove(path, options.recursive));
457
1066
  }
458
1067
  copyFromLocal(localPath, remotePath) {
459
- return this.native.files.upload(localPath, remotePath);
1068
+ return fileCall(remotePath, () => this.native.files.upload(localPath, remotePath));
460
1069
  }
461
1070
  copyToLocal(remotePath, localPath) {
462
- return this.native.files.download(remotePath, localPath);
1071
+ return fileCall(remotePath, () => this.native.files.download(remotePath, localPath));
463
1072
  }
464
1073
  async listFiles(path) {
465
- return Promise.all((await this.files.readdir(path))
466
- .sort((a, b) => a.name.localeCompare(b.name))
467
- .map((e) => this.stat(e.path)));
1074
+ const entries = await fileCall(path, () => this.files.readdir(path));
1075
+ return Promise.all(entries.sort((a, b) => a.name.localeCompare(b.name)).map((e) => this.stat(e.path)));
468
1076
  }
469
1077
  async stat(path) {
470
- const e = await this.files.stat(path);
1078
+ const e = await fileCall(path, () => this.files.stat(path));
471
1079
  const r = await this.native.exec(["stat", "-c", "%U:%G", "--", this.files.path(path)], {
472
1080
  check: true,
473
1081
  });
@@ -487,4 +1095,56 @@ export class SandboxFilesystem {
487
1095
  : null,
488
1096
  };
489
1097
  }
1098
+ /** Changes under `remotePath` until `timeoutMs` passes or the loop stops. */
1099
+ async *watch(remotePath, params = {}) {
1100
+ only("Modal watch", params, ["filter", "recursive", "timeoutMs"]);
1101
+ if (!remotePath.startsWith("/"))
1102
+ throw new InvalidError(`watch() expects an absolute path, got ${remotePath}`);
1103
+ const types = (params.filter ?? ["Access", "Create", "Modify", "Remove"]).flatMap((t) => (t === "Unknown" ? [] : WATCHED[t]));
1104
+ const queue = [];
1105
+ let ended = false;
1106
+ let wake;
1107
+ const notify = () => {
1108
+ wake?.();
1109
+ wake = undefined;
1110
+ };
1111
+ const handle = await fileCall(remotePath, () => this.native.files.watch(remotePath, undefined, {
1112
+ recursive: params.recursive ?? false,
1113
+ events: [...new Set(types)],
1114
+ timeoutMs: params.timeoutMs ?? 0,
1115
+ onBatch: (events) => {
1116
+ for (const e of events)
1117
+ queue.push({
1118
+ eventType: e.type === "create"
1119
+ ? "Create"
1120
+ : e.type === "remove"
1121
+ ? "Remove"
1122
+ : e.type === "access"
1123
+ ? "Access"
1124
+ : "Modify",
1125
+ paths: e.type === "rename" && e.oldPath ? [e.oldPath, e.path] : [e.path],
1126
+ });
1127
+ notify();
1128
+ },
1129
+ onExit: () => {
1130
+ ended = true;
1131
+ notify();
1132
+ },
1133
+ }));
1134
+ try {
1135
+ while (true) {
1136
+ const next = queue.shift();
1137
+ if (next)
1138
+ yield next;
1139
+ else if (ended)
1140
+ return;
1141
+ else
1142
+ await new Promise((resolve) => (wake = resolve));
1143
+ }
1144
+ }
1145
+ finally {
1146
+ if (!ended)
1147
+ await handle.stop().catch(() => undefined);
1148
+ }
1149
+ }
490
1150
  }