@blaxel/core 0.2.67-preview.88 → 0.2.67-preview.90

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 (37) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/common/settings.js +2 -2
  3. package/dist/cjs/image/image.browser.js +84 -0
  4. package/dist/cjs/image/image.js +567 -0
  5. package/dist/cjs/image/image.test.js +545 -0
  6. package/dist/cjs/image/index.js +7 -0
  7. package/dist/cjs/index.js +1 -0
  8. package/dist/cjs/types/image/image.browser.d.ts +59 -0
  9. package/dist/cjs/types/image/image.d.ts +202 -0
  10. package/dist/cjs/types/image/image.test.d.ts +4 -0
  11. package/dist/cjs/types/image/index.d.ts +1 -0
  12. package/dist/cjs/types/index.d.ts +1 -0
  13. package/dist/cjs-browser/.tsbuildinfo +1 -1
  14. package/dist/cjs-browser/common/settings.js +2 -2
  15. package/dist/cjs-browser/image/image.js +84 -0
  16. package/dist/cjs-browser/image/image.test.js +545 -0
  17. package/dist/cjs-browser/image/index.js +7 -0
  18. package/dist/cjs-browser/index.js +1 -0
  19. package/dist/cjs-browser/types/image/image.browser.d.ts +59 -0
  20. package/dist/cjs-browser/types/image/image.d.ts +202 -0
  21. package/dist/cjs-browser/types/image/image.test.d.ts +4 -0
  22. package/dist/cjs-browser/types/image/index.d.ts +1 -0
  23. package/dist/cjs-browser/types/index.d.ts +1 -0
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/common/settings.js +2 -2
  26. package/dist/esm/image/image.browser.js +80 -0
  27. package/dist/esm/image/image.js +560 -0
  28. package/dist/esm/image/image.test.js +543 -0
  29. package/dist/esm/image/index.js +1 -0
  30. package/dist/esm/index.js +1 -0
  31. package/dist/esm-browser/.tsbuildinfo +1 -1
  32. package/dist/esm-browser/common/settings.js +2 -2
  33. package/dist/esm-browser/image/image.js +80 -0
  34. package/dist/esm-browser/image/image.test.js +543 -0
  35. package/dist/esm-browser/image/index.js +1 -0
  36. package/dist/esm-browser/index.js +1 -0
  37. package/package.json +4 -1
@@ -3,8 +3,8 @@ import { authentication } from "../authentication/index.js";
3
3
  import { env } from "../common/env.js";
4
4
  import { fs, os, path } from "../common/node.js";
5
5
  // Build info - these placeholders are replaced at build time by build:replace-imports
6
- const BUILD_VERSION = "0.2.67-preview.88";
7
- const BUILD_COMMIT = "5357b4855456af1c4f172b8489f4f5c520c79a24";
6
+ const BUILD_VERSION = "0.2.67-preview.90";
7
+ const BUILD_COMMIT = "0b5a3b81059ce9031873088b36eed87a0c589f65";
8
8
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
9
9
  // Cache for config.yaml tracking value
10
10
  let configTrackingValue = null;
@@ -0,0 +1,80 @@
1
+ export const SANDBOX_API_IMAGE = "ghcr.io/blaxel-ai/sandbox";
2
+ export const SANDBOX_API_PATH = "/usr/local/bin/sandbox-api";
3
+ function throwBrowserError() {
4
+ throw new Error("ImageInstance is only available in Node.js environments. " +
5
+ "Image building requires file system operations that are not supported in browsers.");
6
+ }
7
+ /**
8
+ * A fluent builder for creating sandbox images programmatically.
9
+ *
10
+ * NOTE: This class is only available in Node.js environments.
11
+ * In browser environments, all methods will throw an error.
12
+ */
13
+ export class ImageInstance {
14
+ _context;
15
+ constructor(_context) {
16
+ this._context = _context;
17
+ throwBrowserError();
18
+ }
19
+ static fromRegistry(_tag) {
20
+ throwBrowserError();
21
+ }
22
+ workdir(_path) {
23
+ throwBrowserError();
24
+ }
25
+ runCommands(..._commands) {
26
+ throwBrowserError();
27
+ }
28
+ env(_variables) {
29
+ throwBrowserError();
30
+ }
31
+ copy(_source, _destination) {
32
+ throwBrowserError();
33
+ }
34
+ addLocalFile(_sourcePath, _destination, _contextName) {
35
+ throwBrowserError();
36
+ }
37
+ addLocalDir(_sourcePath, _destination, _contextName) {
38
+ throwBrowserError();
39
+ }
40
+ expose(..._ports) {
41
+ throwBrowserError();
42
+ }
43
+ entrypoint(..._args) {
44
+ throwBrowserError();
45
+ }
46
+ user(_user) {
47
+ throwBrowserError();
48
+ }
49
+ label(_labels) {
50
+ throwBrowserError();
51
+ }
52
+ arg(_name, _defaultValue) {
53
+ throwBrowserError();
54
+ }
55
+ get dockerfile() {
56
+ throw new Error("ImageInstance is only available in Node.js environments. " +
57
+ "Image building requires file system operations that are not supported in browsers.");
58
+ }
59
+ get hash() {
60
+ throw new Error("ImageInstance is only available in Node.js environments. " +
61
+ "Image building requires file system operations that are not supported in browsers.");
62
+ }
63
+ get baseImage() {
64
+ throw new Error("ImageInstance is only available in Node.js environments. " +
65
+ "Image building requires file system operations that are not supported in browsers.");
66
+ }
67
+ write(_outputPath, _name) {
68
+ throwBrowserError();
69
+ }
70
+ writeTemp() {
71
+ throwBrowserError();
72
+ }
73
+ // eslint-disable-next-line @typescript-eslint/require-await
74
+ async build(_options) {
75
+ throwBrowserError();
76
+ }
77
+ buildSync(_options) {
78
+ throwBrowserError();
79
+ }
80
+ }
@@ -0,0 +1,560 @@
1
+ import { DockerfileParser } from "dockerfile-ast";
2
+ import { crypto, fs, os, path } from "../common/node.js";
3
+ import { settings } from "../common/settings.js";
4
+ import archiver from "archiver";
5
+ function ensureNodeEnvironment() {
6
+ if (!fs || !os || !path || !crypto) {
7
+ throw new Error("Image is only available in Node.js environments. File system operations are not supported in browsers.");
8
+ }
9
+ }
10
+ export const SANDBOX_API_IMAGE = "ghcr.io/blaxel-ai/sandbox";
11
+ export const SANDBOX_API_PATH = "/usr/local/bin/sandbox-api";
12
+ function generateDockerfile(context) {
13
+ // Build the raw Dockerfile content
14
+ const lines = [`FROM ${context.baseImage}`];
15
+ lines.push(...context.instructions);
16
+ const rawContent = lines.join("\n") + "\n";
17
+ // Parse using dockerfile-ast to validate syntax
18
+ const dockerfile = DockerfileParser.parse(rawContent);
19
+ // Check for any parsing errors by validating instruction count matches
20
+ const instructions = dockerfile.getInstructions();
21
+ if (instructions.length !== lines.length) {
22
+ throw new Error("Invalid Dockerfile syntax: instruction count mismatch after parsing");
23
+ }
24
+ // Return raw content to preserve escaping in JSON-format instructions (ENTRYPOINT, CMD)
25
+ // The AST reconstruction via toString() doesn't properly preserve escape sequences
26
+ return rawContent;
27
+ }
28
+ function computeHash(context) {
29
+ ensureNodeEnvironment();
30
+ let content = generateDockerfile(context);
31
+ for (const localFile of context.localFiles) {
32
+ if (!fs.existsSync(localFile.sourcePath)) {
33
+ throw new Error(`Local file not found: ${localFile.sourcePath}. Cannot compute hash for missing files.`);
34
+ }
35
+ const stat = fs.statSync(localFile.sourcePath);
36
+ content += `\n${localFile.contextName}:${stat.mtimeMs}`;
37
+ }
38
+ return crypto.createHash("sha256").update(content).digest("hex").substring(0, 12);
39
+ }
40
+ function cloneContext(context) {
41
+ return {
42
+ baseImage: context.baseImage,
43
+ instructions: [...context.instructions],
44
+ localFiles: [...context.localFiles],
45
+ hasEntrypoint: context.hasEntrypoint,
46
+ };
47
+ }
48
+ function sleep(ms) {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+ /**
52
+ * A fluent builder for creating sandbox images programmatically.
53
+ *
54
+ * Similar to Modal's Image class, allows chaining operations to build
55
+ * a custom image from a base image.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * const image = ImageInstance.fromRegistry("python:3.11-slim")
60
+ * .runCommands("apt-get update && apt-get install -y git curl")
61
+ * .workdir("/app")
62
+ * .runCommands("pip install --upgrade pip")
63
+ * .env({ PYTHONUNBUFFERED: "1" });
64
+ *
65
+ * await image.build({
66
+ * name: "my-sandbox",
67
+ * memory: 4096,
68
+ * timeout: 900000,
69
+ * onStatusChange: console.log,
70
+ * sandboxVersion: "latest",
71
+ * });
72
+ * ```
73
+ */
74
+ export class ImageInstance {
75
+ _context;
76
+ constructor(context) {
77
+ this._context = context;
78
+ }
79
+ /**
80
+ * Create an image from a Docker registry image.
81
+ *
82
+ * @param tag - The image tag (e.g., "python:3.11-slim", "ubuntu:22.04")
83
+ * @returns A new Image instance
84
+ */
85
+ static fromRegistry(tag) {
86
+ const context = {
87
+ baseImage: tag,
88
+ instructions: [],
89
+ localFiles: [],
90
+ hasEntrypoint: false,
91
+ };
92
+ return new ImageInstance(context);
93
+ }
94
+ /**
95
+ * Set the working directory for subsequent instructions.
96
+ *
97
+ * @param path - The working directory path inside the container
98
+ * @returns A new Image instance with the working directory set
99
+ */
100
+ workdir(path) {
101
+ const newContext = cloneContext(this._context);
102
+ newContext.instructions.push(`WORKDIR ${path}`);
103
+ return new ImageInstance(newContext);
104
+ }
105
+ /**
106
+ * Run shell commands in the image.
107
+ *
108
+ * @param commands - One or more shell commands to run
109
+ * @returns A new Image instance with the commands added
110
+ */
111
+ runCommands(...commands) {
112
+ const newContext = cloneContext(this._context);
113
+ for (const cmd of commands) {
114
+ newContext.instructions.push(`RUN ${cmd}`);
115
+ }
116
+ return new ImageInstance(newContext);
117
+ }
118
+ /**
119
+ * Set environment variables.
120
+ *
121
+ * @param variables - Environment variables as an object
122
+ * @returns A new Image instance with the environment variables set
123
+ */
124
+ env(variables) {
125
+ const keys = Object.keys(variables);
126
+ if (keys.length === 0) {
127
+ return this;
128
+ }
129
+ const newContext = cloneContext(this._context);
130
+ for (const [key, value] of Object.entries(variables)) {
131
+ newContext.instructions.push(`ENV ${key}="${value}"`);
132
+ }
133
+ return new ImageInstance(newContext);
134
+ }
135
+ /**
136
+ * Copy files or directories from the build context to the image.
137
+ *
138
+ * @param source - Source path (relative to build context)
139
+ * @param destination - Destination path in the image
140
+ * @returns A new Image instance with the copy instruction
141
+ */
142
+ copy(source, destination) {
143
+ const newContext = cloneContext(this._context);
144
+ newContext.instructions.push(`COPY ${source} ${destination}`);
145
+ return new ImageInstance(newContext);
146
+ }
147
+ /**
148
+ * Add a local file to the build context and copy it to the image.
149
+ *
150
+ * @param sourcePath - Path to the local file
151
+ * @param destination - Destination path in the image
152
+ * @param contextName - Optional name for the file in the build context
153
+ * @returns A new Image instance with the file added
154
+ */
155
+ addLocalFile(sourcePath, destination, contextName) {
156
+ ensureNodeEnvironment();
157
+ const source = path.resolve(sourcePath);
158
+ const name = contextName ?? path.basename(source);
159
+ const newContext = cloneContext(this._context);
160
+ newContext.localFiles.push({
161
+ sourcePath: source,
162
+ destinationPath: destination,
163
+ contextName: name,
164
+ });
165
+ newContext.instructions.push(`COPY ${name} ${destination}`);
166
+ return new ImageInstance(newContext);
167
+ }
168
+ /**
169
+ * Add a local directory to the build context and copy it to the image.
170
+ *
171
+ * @param sourcePath - Path to the local directory
172
+ * @param destination - Destination path in the image
173
+ * @param contextName - Optional name for the directory in the build context
174
+ * @returns A new Image instance with the directory added
175
+ */
176
+ addLocalDir(sourcePath, destination, contextName) {
177
+ ensureNodeEnvironment();
178
+ const source = path.resolve(sourcePath);
179
+ const name = contextName ?? path.basename(source);
180
+ const newContext = cloneContext(this._context);
181
+ newContext.localFiles.push({
182
+ sourcePath: source,
183
+ destinationPath: destination,
184
+ contextName: name,
185
+ });
186
+ newContext.instructions.push(`COPY ${name} ${destination}`);
187
+ return new ImageInstance(newContext);
188
+ }
189
+ /**
190
+ * Expose ports.
191
+ *
192
+ * @param ports - Port numbers to expose
193
+ * @returns A new Image instance with the ports exposed
194
+ */
195
+ expose(...ports) {
196
+ if (ports.length === 0) {
197
+ return this;
198
+ }
199
+ const newContext = cloneContext(this._context);
200
+ for (const port of ports) {
201
+ newContext.instructions.push(`EXPOSE ${port}`);
202
+ }
203
+ return new ImageInstance(newContext);
204
+ }
205
+ /**
206
+ * Set the entrypoint for the image.
207
+ *
208
+ * @param args - Entrypoint command and arguments
209
+ * @returns A new Image instance with the entrypoint set
210
+ */
211
+ entrypoint(...args) {
212
+ if (args.length === 0) {
213
+ return this;
214
+ }
215
+ // Format as JSON array for exec form, using JSON.stringify to properly escape quotes and special characters
216
+ const argsJson = args.map((arg) => JSON.stringify(arg)).join(", ");
217
+ const newContext = cloneContext(this._context);
218
+ newContext.instructions.push(`ENTRYPOINT [${argsJson}]`);
219
+ newContext.hasEntrypoint = true;
220
+ return new ImageInstance(newContext);
221
+ }
222
+ /**
223
+ * Set the user for subsequent instructions.
224
+ *
225
+ * @param user - Username or UID
226
+ * @returns A new Image instance with the user set
227
+ */
228
+ user(user) {
229
+ const newContext = cloneContext(this._context);
230
+ newContext.instructions.push(`USER ${user}`);
231
+ return new ImageInstance(newContext);
232
+ }
233
+ /**
234
+ * Add labels to the image.
235
+ *
236
+ * @param labels - Labels as an object
237
+ * @returns A new Image instance with the labels added
238
+ */
239
+ label(labels) {
240
+ const keys = Object.keys(labels);
241
+ if (keys.length === 0) {
242
+ return this;
243
+ }
244
+ const newContext = cloneContext(this._context);
245
+ for (const [key, value] of Object.entries(labels)) {
246
+ newContext.instructions.push(`LABEL ${key}="${value}"`);
247
+ }
248
+ return new ImageInstance(newContext);
249
+ }
250
+ /**
251
+ * Define a build argument.
252
+ *
253
+ * @param name - Argument name
254
+ * @param defaultValue - Optional default value
255
+ * @returns A new Image instance with the argument defined
256
+ */
257
+ arg(name, defaultValue) {
258
+ const newContext = cloneContext(this._context);
259
+ if (defaultValue !== undefined) {
260
+ newContext.instructions.push(`ARG ${name}=${defaultValue}`);
261
+ }
262
+ else {
263
+ newContext.instructions.push(`ARG ${name}`);
264
+ }
265
+ return new ImageInstance(newContext);
266
+ }
267
+ /**
268
+ * Get the generated Dockerfile content.
269
+ */
270
+ get dockerfile() {
271
+ return generateDockerfile(this._context);
272
+ }
273
+ /**
274
+ * Get a hash of the image configuration.
275
+ */
276
+ get hash() {
277
+ return computeHash(this._context);
278
+ }
279
+ /**
280
+ * Get the base image tag.
281
+ */
282
+ get baseImage() {
283
+ return this._context.baseImage;
284
+ }
285
+ _hasSandboxApi() {
286
+ const dockerfile = generateDockerfile(this._context);
287
+ return dockerfile.includes("sandbox-api") || dockerfile.includes("blaxel-ai/sandbox");
288
+ }
289
+ _prepareForSandbox(sandboxVersion = "latest") {
290
+ const newContext = cloneContext(this._context);
291
+ // Add sandbox-api if not already present
292
+ if (!this._hasSandboxApi()) {
293
+ const sandboxImage = `${SANDBOX_API_IMAGE}:${sandboxVersion}`;
294
+ const copyInstruction = `COPY --from=${sandboxImage} /sandbox-api ${SANDBOX_API_PATH}`;
295
+ newContext.instructions.push(copyInstruction);
296
+ }
297
+ // Add default entrypoint if not set by user
298
+ if (!newContext.hasEntrypoint) {
299
+ newContext.instructions.push(`ENTRYPOINT ["${SANDBOX_API_PATH}"]`);
300
+ newContext.hasEntrypoint = true;
301
+ }
302
+ return new ImageInstance(newContext);
303
+ }
304
+ /**
305
+ * Write the image to a deployable folder structure.
306
+ *
307
+ * @param outputPath - Path to the output directory
308
+ * @param name - Optional name for the generated folder (defaults to hash-based name)
309
+ * @returns Path to the generated folder
310
+ */
311
+ write(outputPath, name) {
312
+ ensureNodeEnvironment();
313
+ const outputDir = path.resolve(outputPath);
314
+ // Create folder name based on hash if not provided
315
+ if (!name) {
316
+ name = `image-${this.hash}`;
317
+ }
318
+ const buildDir = path.join(outputDir, name);
319
+ fs.mkdirSync(buildDir, { recursive: true });
320
+ // Generate Dockerfile
321
+ const dockerfilePath = path.join(buildDir, "Dockerfile");
322
+ fs.writeFileSync(dockerfilePath, generateDockerfile(this._context));
323
+ // Copy local files to build context
324
+ for (const localFile of this._context.localFiles) {
325
+ if (!fs.existsSync(localFile.sourcePath)) {
326
+ throw new Error(`Local file not found: ${localFile.sourcePath}`);
327
+ }
328
+ const dest = path.join(buildDir, localFile.contextName);
329
+ const stat = fs.statSync(localFile.sourcePath);
330
+ if (stat.isDirectory()) {
331
+ if (fs.existsSync(dest)) {
332
+ fs.rmSync(dest, { recursive: true });
333
+ }
334
+ fs.cpSync(localFile.sourcePath, dest, { recursive: true });
335
+ }
336
+ else {
337
+ fs.cpSync(localFile.sourcePath, dest);
338
+ }
339
+ }
340
+ // Generate a manifest file with metadata
341
+ const manifest = {
342
+ base_image: this._context.baseImage,
343
+ hash: this.hash,
344
+ instructions_count: this._context.instructions.length,
345
+ local_files_count: this._context.localFiles.length,
346
+ };
347
+ const manifestPath = path.join(buildDir, "manifest.json");
348
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
349
+ return buildDir;
350
+ }
351
+ /**
352
+ * Write the image to a deployable folder in a temporary directory.
353
+ *
354
+ * @returns Path to the generated folder
355
+ */
356
+ writeTemp() {
357
+ ensureNodeEnvironment();
358
+ const tempDir = path.join(os.tmpdir(), `blaxel-image-${Date.now()}`);
359
+ fs.mkdirSync(tempDir, { recursive: true });
360
+ return this.write(tempDir);
361
+ }
362
+ async _createZip(buildDir) {
363
+ return new Promise((resolve, reject) => {
364
+ const chunks = [];
365
+ const archive = archiver("zip", {
366
+ zlib: { level: 9 },
367
+ });
368
+ archive.on("data", (chunk) => {
369
+ chunks.push(chunk);
370
+ });
371
+ archive.on("end", () => {
372
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
373
+ const result = new Uint8Array(totalLength);
374
+ let offset = 0;
375
+ for (const chunk of chunks) {
376
+ result.set(chunk, offset);
377
+ offset += chunk.length;
378
+ }
379
+ resolve(result);
380
+ });
381
+ archive.on("error", reject);
382
+ // Add all files from the build directory
383
+ archive.directory(buildDir, false);
384
+ void archive.finalize();
385
+ });
386
+ }
387
+ _createSandboxPayload(name, memory = 4096) {
388
+ const labels = {
389
+ "x-blaxel-auto-generated": "true",
390
+ };
391
+ const metadata = {
392
+ name,
393
+ labels,
394
+ };
395
+ const runtime = {
396
+ memory,
397
+ };
398
+ const spec = {
399
+ runtime,
400
+ };
401
+ return {
402
+ metadata,
403
+ spec,
404
+ };
405
+ }
406
+ async _createSandboxWithUpload(sandbox) {
407
+ const name = sandbox.metadata?.name || "";
408
+ const body = sandbox;
409
+ await settings.authenticate();
410
+ const headers = {
411
+ "Content-Type": "application/json",
412
+ ...settings.headers,
413
+ };
414
+ // Try PUT first (update), fall back to POST (create)
415
+ let response = await fetch(`${settings.baseUrl}/sandboxes/${name}?upload=true`, {
416
+ method: "PUT",
417
+ headers,
418
+ body: JSON.stringify(body),
419
+ });
420
+ // If 404, try create
421
+ if (response.status === 404) {
422
+ response = await fetch(`${settings.baseUrl}/sandboxes?upload=true`, {
423
+ method: "POST",
424
+ headers,
425
+ body: JSON.stringify(body),
426
+ });
427
+ }
428
+ const uploadUrl = response.headers.get("x-blaxel-upload-url");
429
+ return { response, uploadUrl };
430
+ }
431
+ async _uploadZip(uploadUrl, zipContent) {
432
+ const response = await fetch(uploadUrl, {
433
+ method: "PUT",
434
+ headers: {
435
+ "Content-Type": "application/zip",
436
+ },
437
+ body: zipContent,
438
+ });
439
+ if (response.status >= 400) {
440
+ const text = await response.text();
441
+ throw new Error(`Upload failed with status ${response.status}: ${text}`);
442
+ }
443
+ }
444
+ async _getSandboxStatus(name) {
445
+ await settings.authenticate();
446
+ const response = await fetch(`${settings.baseUrl}/sandboxes/${name}`, {
447
+ method: "GET",
448
+ headers: settings.headers,
449
+ });
450
+ if (response.status === 200) {
451
+ const data = (await response.json());
452
+ return data.status || null;
453
+ }
454
+ return null;
455
+ }
456
+ async _waitForDeployment(name, timeout = 900000, // 15 minutes in ms
457
+ pollInterval = 3000, onStatusChange) {
458
+ const startTime = Date.now();
459
+ let lastStatus = null;
460
+ const terminalStates = new Set(["DEPLOYED", "FAILED", "TERMINATED"]);
461
+ let buildStarted = false;
462
+ while (Date.now() - startTime < timeout) {
463
+ const status = await this._getSandboxStatus(name);
464
+ if (status && status !== lastStatus) {
465
+ lastStatus = status;
466
+ if (onStatusChange) {
467
+ onStatusChange(status);
468
+ }
469
+ }
470
+ // Track if the build has started (status changed from DEPLOYED)
471
+ if (status && status !== "DEPLOYED") {
472
+ buildStarted = true;
473
+ }
474
+ // Only consider DEPLOYED as terminal if the build has started
475
+ // This handles re-builds where status starts as DEPLOYED
476
+ if (status && terminalStates.has(status)) {
477
+ if (status === "FAILED") {
478
+ throw new Error(`Deployment failed for sandbox '${name}'`);
479
+ }
480
+ if (status === "TERMINATED") {
481
+ throw new Error(`Sandbox '${name}' was terminated`);
482
+ }
483
+ if (status === "DEPLOYED" && buildStarted) {
484
+ return status;
485
+ }
486
+ }
487
+ await sleep(pollInterval);
488
+ }
489
+ throw new Error(`Deployment timed out after ${timeout / 1000} seconds`);
490
+ }
491
+ /**
492
+ * Build and deploy the image as a sandbox.
493
+ *
494
+ * This method:
495
+ * 1. Prepares the image for sandbox deployment (adds sandbox-api)
496
+ * 2. Builds the image folder
497
+ * 3. Creates a zip of the folder
498
+ * 4. Creates/updates the sandbox resource
499
+ * 5. Uploads the zip to Blaxel
500
+ * 6. Waits for deployment to complete
501
+ *
502
+ * @param options - Build options
503
+ * @returns The deployed Sandbox object
504
+ */
505
+ async build(options) {
506
+ const { name, memory = 4096, timeout = 900000, onStatusChange, sandboxVersion = "latest" } = options;
507
+ // Prepare image for sandbox deployment (add sandbox-api and entrypoint)
508
+ const preparedImage = this._prepareForSandbox(sandboxVersion);
509
+ // Write the image folder
510
+ const buildDir = preparedImage.writeTemp();
511
+ try {
512
+ // Create zip
513
+ const zipContent = await this._createZip(buildDir);
514
+ // Create sandbox payload
515
+ const sandboxPayload = this._createSandboxPayload(name, memory);
516
+ // Create/update sandbox and get upload URL
517
+ const { response, uploadUrl } = await this._createSandboxWithUpload(sandboxPayload);
518
+ if (response.status >= 400) {
519
+ const text = await response.text();
520
+ throw new Error(`Failed to create sandbox: ${response.status} - ${text}`);
521
+ }
522
+ if (!uploadUrl) {
523
+ throw new Error("No upload URL returned from API");
524
+ }
525
+ // Upload the zip
526
+ await this._uploadZip(uploadUrl, zipContent);
527
+ // Wait for deployment to complete
528
+ await this._waitForDeployment(name, timeout, 3000, onStatusChange);
529
+ // Get the final sandbox state
530
+ await settings.authenticate();
531
+ const finalResponse = await fetch(`${settings.baseUrl}/sandboxes/${name}`, {
532
+ method: "GET",
533
+ headers: settings.headers,
534
+ });
535
+ if (finalResponse.status === 200) {
536
+ const sandbox = (await finalResponse.json());
537
+ return sandbox;
538
+ }
539
+ throw new Error(`Failed to get sandbox '${name}' after deployment`);
540
+ }
541
+ finally {
542
+ // Cleanup temp directory
543
+ const parentDir = path.resolve(buildDir, "..");
544
+ try {
545
+ fs.rmSync(parentDir, { recursive: true, force: true });
546
+ }
547
+ catch {
548
+ // Ignore cleanup errors
549
+ }
550
+ }
551
+ }
552
+ /**
553
+ * Build and deploy the image as a sandbox (sync version).
554
+ *
555
+ * @deprecated Use build() instead - this is provided for API parity with Python SDK
556
+ */
557
+ buildSync(options) {
558
+ return this.build(options);
559
+ }
560
+ }