disk 0.8.18 → 0.8.20

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.
package/dist/index.cjs CHANGED
@@ -1,1258 +1,1487 @@
1
- "use strict";
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
2
3
  var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
4
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
7
  var __getProtoOf = Object.getPrototypeOf;
7
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __export = (target, all) => {
9
- for (var name in all)
10
- __defProp(target, name, { get: all[name], enumerable: true });
11
- };
12
9
  var __copyProps = (to, from, except, desc) => {
13
- if (from && typeof from === "object" || typeof from === "function") {
14
- for (let key of __getOwnPropNames(from))
15
- if (!__hasOwnProp.call(to, key) && key !== except)
16
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
- }
18
- return to;
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
19
18
  };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
-
30
- // src/index.ts
31
- var index_exports = {};
32
- __export(index_exports, {
33
- Archil: () => Archil,
34
- ArchilApiError: () => ArchilApiError,
35
- ArchilError: () => ArchilError,
36
- ArchilS3Error: () => ArchilS3Error,
37
- Disk: () => Disk,
38
- DiskMultipart: () => DiskMultipart,
39
- Disks: () => Disks,
40
- Tokens: () => Tokens,
41
- USER_AGENT: () => USER_AGENT,
42
- VERSION: () => VERSION,
43
- configure: () => configure,
44
- createApiKey: () => createApiKey,
45
- createDisk: () => createDisk,
46
- deleteApiKey: () => deleteApiKey,
47
- effectiveUploadPartSize: () => effectiveUploadPartSize,
48
- exec: () => exec,
49
- getDisk: () => getDisk,
50
- listApiKeys: () => listApiKeys,
51
- listDisks: () => listDisks
52
- });
53
- module.exports = __toCommonJS(index_exports);
54
-
55
- // src/client.ts
56
- var import_openapi_fetch = __toESM(require("openapi-fetch"), 1);
57
-
58
- // src/s3xml.ts
59
- var import_fast_xml_parser = require("fast-xml-parser");
60
- var parser = new import_fast_xml_parser.XMLParser({
61
- isArray: (name) => name === "Contents" || name === "CommonPrefixes",
62
- parseTagValue: false,
63
- trimValues: true,
64
- ignoreAttributes: true
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ const require_paths = require("./paths-1c4QqbOB.cjs");
25
+ let openapi_fetch = require("openapi-fetch");
26
+ openapi_fetch = __toESM(openapi_fetch, 1);
27
+ //#region src/s3xml.ts
28
+ const parser = new (require("fast-xml-parser")).XMLParser({
29
+ isArray: (name) => name === "Contents" || name === "CommonPrefixes",
30
+ parseTagValue: false,
31
+ trimValues: true,
32
+ ignoreAttributes: true
65
33
  });
34
+ /** Parse an XML document into a plain object. Returns {} for empty/blank input. */
66
35
  function parseXml(xml) {
67
- if (!xml.trim()) return {};
68
- return parser.parse(xml);
36
+ if (!xml.trim()) return {};
37
+ return parser.parse(xml);
69
38
  }
70
-
71
- // src/errors.ts
39
+ //#endregion
40
+ //#region src/errors.ts
41
+ /**
42
+ * Base class for every error the SDK throws. Catch with `instanceof ArchilError`
43
+ * to handle control-plane and S3 failures uniformly; `status` is the HTTP status
44
+ * code and `code` a machine-readable error code when the server provided one.
45
+ */
72
46
  var ArchilError = class extends Error {
73
- /** HTTP status code associated with the failure. */
74
- status;
75
- /** Machine-readable error code (e.g. an S3 code like "NoSuchKey"), if known. */
76
- code;
77
- constructor(message, status, code) {
78
- super(message);
79
- this.name = "ArchilError";
80
- this.status = status;
81
- this.code = code;
82
- }
47
+ /** HTTP status code associated with the failure. */
48
+ status;
49
+ /** Machine-readable error code (e.g. an S3 code like "NoSuchKey"), if known. */
50
+ code;
51
+ constructor(message, status, code) {
52
+ super(message);
53
+ this.name = "ArchilError";
54
+ this.status = status;
55
+ this.code = code;
56
+ }
83
57
  };
58
+ /** Error from the control-plane REST API. */
84
59
  var ArchilApiError = class extends ArchilError {
85
- constructor(message, status, code) {
86
- super(message, status, code);
87
- this.name = "ArchilApiError";
88
- }
60
+ constructor(message, status, code) {
61
+ super(message, status, code);
62
+ this.name = "ArchilApiError";
63
+ }
89
64
  };
65
+ /**
66
+ * Error from the S3-compatible object API (getObject/putObject/deleteObject/
67
+ * headObject/listObjects). The gateway returns an S3-style XML `<Error>` body;
68
+ * this surfaces its parts as structured fields (`status`, `code`, `requestId`)
69
+ * rather than a raw blob, while keeping the full body on `raw` for debugging.
70
+ */
90
71
  var ArchilS3Error = class extends ArchilError {
91
- /** S3 request id, if the gateway returned one. */
92
- requestId;
93
- /** Raw response body (the XML document), for debugging. */
94
- raw;
95
- constructor(opts) {
96
- const detail = opts.message ?? opts.statusText ?? "";
97
- const codePart = opts.code ? ` ${opts.code}` : "";
98
- super(
99
- `S3 ${opts.operation} failed: ${opts.statusCode}${codePart}${detail ? ` \u2014 ${detail}` : ""}`,
100
- opts.statusCode,
101
- opts.code
102
- );
103
- this.name = "ArchilS3Error";
104
- this.requestId = opts.requestId;
105
- this.raw = opts.raw;
106
- }
72
+ /** S3 request id, if the gateway returned one. */
73
+ requestId;
74
+ /** Raw response body (the XML document), for debugging. */
75
+ raw;
76
+ constructor(opts) {
77
+ const detail = opts.message ?? opts.statusText ?? "";
78
+ const codePart = opts.code ? ` ${opts.code}` : "";
79
+ super(`S3 ${opts.operation} failed: ${opts.statusCode}${codePart}${detail ? ` — ${detail}` : ""}`, opts.statusCode, opts.code);
80
+ this.name = "ArchilS3Error";
81
+ this.requestId = opts.requestId;
82
+ this.raw = opts.raw;
83
+ }
107
84
  };
108
85
  function tagString(obj, tag) {
109
- const value = obj[tag];
110
- return value === void 0 || value === null ? void 0 : String(value);
86
+ const value = obj[tag];
87
+ return value === void 0 || value === null ? void 0 : String(value);
111
88
  }
89
+ /** Build an ArchilS3Error from a failed S3 response, parsing the XML body. */
112
90
  function parseS3Error(operation, statusCode, statusText, body) {
113
- let err = {};
114
- try {
115
- err = parseXml(body).Error ?? {};
116
- } catch {
117
- err = {};
118
- }
119
- return new ArchilS3Error({
120
- operation,
121
- statusCode,
122
- statusText,
123
- code: tagString(err, "Code"),
124
- message: tagString(err, "Message"),
125
- requestId: tagString(err, "RequestId"),
126
- raw: body
127
- });
91
+ let err = {};
92
+ try {
93
+ err = parseXml(body).Error ?? {};
94
+ } catch {
95
+ err = {};
96
+ }
97
+ return new ArchilS3Error({
98
+ operation,
99
+ statusCode,
100
+ statusText,
101
+ code: tagString(err, "Code"),
102
+ message: tagString(err, "Message"),
103
+ requestId: tagString(err, "RequestId"),
104
+ raw: body
105
+ });
128
106
  }
129
-
130
- // src/regions.ts
131
- var REGION_URLS = {
132
- "aws-us-east-1": "https://control.green.us-east-1.aws.prod.archil.com",
133
- "aws-us-west-2": "https://control.green.us-west-2.aws.prod.archil.com",
134
- "aws-eu-west-1": "https://control.green.eu-west-1.aws.prod.archil.com",
135
- "gcp-us-central1": "https://control.blue.us-central1.gcp.prod.archil.com"
107
+ //#endregion
108
+ //#region src/regions.ts
109
+ const REGION_URLS = {
110
+ "aws-us-east-1": "https://control.green.us-east-1.aws.prod.archil.com",
111
+ "aws-us-west-2": "https://control.green.us-west-2.aws.prod.archil.com",
112
+ "aws-eu-west-1": "https://control.green.eu-west-1.aws.prod.archil.com",
113
+ "gcp-us-central1": "https://control.blue.us-central1.gcp.prod.archil.com"
136
114
  };
137
115
  function resolveBaseUrl(region) {
138
- const url = REGION_URLS[region];
139
- if (!url) {
140
- throw new Error(
141
- `Unknown region "${region}". Valid regions: ${Object.keys(REGION_URLS).join(", ")}`
142
- );
143
- }
144
- return url;
116
+ const url = REGION_URLS[region];
117
+ if (!url) throw new Error(`Unknown region "${region}". Valid regions: ${Object.keys(REGION_URLS).join(", ")}`);
118
+ return url;
145
119
  }
120
+ /**
121
+ * Derive the S3-compatible endpoint from a control-plane base URL by swapping a
122
+ * leading `control.` hostname segment for `s3.` (e.g.
123
+ * `control.green.us-east-1.…` → `s3.green.us-east-1.…`). Returns undefined if the
124
+ * URL can't be parsed. A host without a `control.` prefix is returned unchanged.
125
+ */
146
126
  function deriveS3BaseUrl(controlBaseUrl) {
147
- try {
148
- const u = new URL(controlBaseUrl);
149
- u.hostname = u.hostname.replace(/^control\./, "s3.");
150
- return u.toString().replace(/\/$/, "");
151
- } catch {
152
- return void 0;
153
- }
127
+ try {
128
+ const u = new URL(controlBaseUrl);
129
+ u.hostname = u.hostname.replace(/^control\./, "s3.");
130
+ return u.toString().replace(/\/$/, "");
131
+ } catch {
132
+ return;
133
+ }
154
134
  }
155
-
156
- // src/version.ts
157
- var VERSION = true ? "0.8.18" : "0.0.0-dev";
158
- var USER_AGENT = `archil-js/${VERSION}`;
159
-
160
- // src/client.ts
161
- var createClient = typeof import_openapi_fetch.default === "function" ? import_openapi_fetch.default : import_openapi_fetch.default.default;
135
+ //#endregion
136
+ //#region src/version.ts
137
+ const VERSION = "0.8.20";
138
+ const USER_AGENT = `archil-js/${VERSION}`;
139
+ //#endregion
140
+ //#region src/client.ts
141
+ const createClient = typeof openapi_fetch.default === "function" ? openapi_fetch.default : openapi_fetch.default.default;
162
142
  function createApiClient(opts) {
163
- const baseUrl = opts.baseUrl ?? resolveBaseUrl(opts.region);
164
- return createClient({
165
- baseUrl,
166
- headers: {
167
- Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`,
168
- // Identifies the JS SDK (and its version) to the control plane. Honored
169
- // by Node's fetch; browsers treat User-Agent as a forbidden header and
170
- // drop it, which is fine — the SDK's primary use is server-side.
171
- "User-Agent": USER_AGENT
172
- }
173
- });
143
+ const baseUrl = opts.baseUrl ?? resolveBaseUrl(opts.region);
144
+ return createClient({
145
+ baseUrl,
146
+ headers: {
147
+ Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`,
148
+ "User-Agent": USER_AGENT
149
+ }
150
+ });
174
151
  }
152
+ /**
153
+ * Unwrap the API envelope: return data on success, throw ArchilApiError on failure.
154
+ */
175
155
  async function unwrap(promise) {
176
- const { data: body, error, response } = await promise;
177
- if (error || !body) {
178
- const errBody = error;
179
- throw new ArchilApiError(
180
- errBody?.error ?? `API request failed with status ${response.status}`,
181
- response.status
182
- );
183
- }
184
- if (!body.success) {
185
- throw new ArchilApiError(
186
- body.error ?? "Unknown API error",
187
- response.status
188
- );
189
- }
190
- return body.data;
156
+ return (await unwrapPage(promise)).data;
191
157
  }
158
+ /**
159
+ * Unwrap a paginated list envelope: like {@link unwrap}, but also surface the
160
+ * envelope's `nextCursor` (undefined on the last page or from a server that
161
+ * doesn't paginate).
162
+ */
163
+ async function unwrapPage(promise) {
164
+ const { data: body, error, response } = await promise;
165
+ if (error || !body) throw new ArchilApiError(error?.error ?? `API request failed with status ${response.status}`, response.status);
166
+ if (!body.success) throw new ArchilApiError(body.error ?? "Unknown API error", response.status);
167
+ return {
168
+ data: body.data,
169
+ nextCursor: body.nextCursor
170
+ };
171
+ }
172
+ /**
173
+ * Unwrap an API response that has no data payload (e.g., delete operations).
174
+ */
192
175
  async function unwrapEmpty(promise) {
193
- const { data: body, error, response } = await promise;
194
- if (error || !body) {
195
- const errBody = error;
196
- throw new ArchilApiError(
197
- errBody?.error ?? `API request failed with status ${response.status}`,
198
- response.status
199
- );
200
- }
201
- if (!body.success) {
202
- throw new ArchilApiError(
203
- body.error ?? "Unknown API error",
204
- response.status
205
- );
206
- }
176
+ const { data: body, error, response } = await promise;
177
+ if (error || !body) throw new ArchilApiError(error?.error ?? `API request failed with status ${response.status}`, response.status);
178
+ if (!body.success) throw new ArchilApiError(body.error ?? "Unknown API error", response.status);
207
179
  }
208
-
209
- // src/disk.ts
180
+ //#endregion
181
+ //#region src/disk.ts
210
182
  var Disk = class {
211
- id;
212
- name;
213
- organization;
214
- status;
215
- provider;
216
- region;
217
- createdAt;
218
- fsHandlerStatus;
219
- lastAccessed;
220
- dataSize;
221
- monthlyUsage;
222
- mounts;
223
- metrics;
224
- connectedClients;
225
- authorizedUsers;
226
- allowedIps;
227
- /** @internal */
228
- _client;
229
- /** @internal */
230
- _archilRegion;
231
- /** Base URL for the S3-compatible API (port 9000 ingress). Empty if unset. */
232
- _s3BaseUrl;
233
- /** Lazily-constructed multipart namespace (see {@link multipart}). @internal */
234
- _multipart;
235
- /** @internal */
236
- constructor(data, client, archilRegion, s3BaseUrl) {
237
- this.id = data.id;
238
- this.name = data.name;
239
- this.organization = data.organization;
240
- this.status = data.status;
241
- this.provider = data.provider;
242
- this.region = data.region;
243
- this.createdAt = data.createdAt;
244
- this.fsHandlerStatus = data.fsHandlerStatus;
245
- this.lastAccessed = data.lastAccessed;
246
- this.dataSize = data.dataSize;
247
- this.monthlyUsage = data.monthlyUsage;
248
- this.mounts = data.mounts;
249
- this.metrics = data.metrics;
250
- this.connectedClients = data.connectedClients;
251
- this.authorizedUsers = data.authorizedUsers;
252
- this.allowedIps = data.allowedIps;
253
- this._client = client;
254
- this._archilRegion = archilRegion;
255
- this._s3BaseUrl = s3BaseUrl ?? "";
256
- }
257
- toJSON() {
258
- return {
259
- id: this.id,
260
- name: this.name,
261
- organization: this.organization,
262
- status: this.status,
263
- provider: this.provider,
264
- region: this.region,
265
- createdAt: this.createdAt,
266
- fsHandlerStatus: this.fsHandlerStatus,
267
- lastAccessed: this.lastAccessed,
268
- dataSize: this.dataSize,
269
- monthlyUsage: this.monthlyUsage,
270
- mounts: this.mounts,
271
- metrics: this.metrics,
272
- connectedClients: this.connectedClients,
273
- authorizedUsers: this.authorizedUsers,
274
- allowedIps: this.allowedIps
275
- };
276
- }
277
- async addUser(user) {
278
- return unwrap(
279
- this._client.POST("/api/disks/{id}/users", {
280
- params: { path: { id: this.id } },
281
- body: user
282
- })
283
- );
284
- }
285
- async removeUser(userType, identifier) {
286
- await unwrapEmpty(
287
- this._client.DELETE("/api/disks/{id}/users/{userType}", {
288
- params: {
289
- path: { id: this.id, userType },
290
- query: { identifier }
291
- }
292
- })
293
- );
294
- }
295
- async createToken(nickname) {
296
- const user = await unwrap(
297
- this._client.POST("/api/disks/{id}/users", {
298
- params: { path: { id: this.id } },
299
- body: { type: "token", nickname }
300
- })
301
- );
302
- if (!user.token || !user.identifier) {
303
- throw new Error("Server did not return a generated token");
304
- }
305
- return user;
306
- }
307
- async removeTokenUser(identifier) {
308
- await this.removeUser("token", identifier);
309
- }
310
- async getAllowedIPs() {
311
- const data = await unwrap(
312
- this._client.GET("/api/disks/{id}/allowed-ips", {
313
- params: { path: { id: this.id } }
314
- })
315
- );
316
- return data.allowedIps;
317
- }
318
- async setAllowedIPs(allowedIps) {
319
- const data = await unwrap(
320
- this._client.PUT("/api/disks/{id}/allowed-ips", {
321
- params: { path: { id: this.id } },
322
- body: { allowedIps }
323
- })
324
- );
325
- return data.allowedIps;
326
- }
327
- async addAllowedIP(ip) {
328
- const current = await this.getAllowedIPs();
329
- if (current.includes(ip)) return current;
330
- return this.setAllowedIPs([...current, ip]);
331
- }
332
- async removeAllowedIP(ip) {
333
- const current = await this.getAllowedIPs();
334
- return this.setAllowedIPs(current.filter((i) => i !== ip));
335
- }
336
- async delete() {
337
- await unwrapEmpty(
338
- this._client.DELETE("/api/disks/{id}", {
339
- params: { path: { id: this.id } }
340
- })
341
- );
342
- }
343
- /**
344
- * Execute a command in a container with this disk mounted.
345
- * Blocks until the command completes and returns stdout, stderr, and exit code.
346
- */
347
- async exec(command) {
348
- return unwrap(
349
- this._client.POST("/api/disks/{id}/exec", {
350
- params: { path: { id: this.id } },
351
- body: { command }
352
- })
353
- );
354
- }
355
- /**
356
- * Constant-time parallel grep across files on this disk. Listing and
357
- * matching are fanned out across ephemeral exec containers; the request
358
- * finishes within the supplied time budget regardless of dataset size.
359
- *
360
- * The returned `stoppedReason` says whether the search ran to completion
361
- * or short-circuited on `maxResults` / `maxDurationSeconds`. When
362
- * stopping early, the matches returned are a sample (whichever workers
363
- * reported first), not the lexicographically first N.
364
- */
365
- async grep(opts) {
366
- return unwrap(
367
- this._client.POST("/api/disks/{id}/grep", {
368
- params: { path: { id: this.id } },
369
- body: {
370
- directory: opts.directory,
371
- pattern: opts.pattern,
372
- recursive: opts.recursive ?? false,
373
- maxDurationSeconds: opts.maxDurationSeconds ?? 30,
374
- concurrency: opts.concurrency ?? 50,
375
- maxResults: opts.maxResults ?? 1e3
376
- }
377
- })
378
- );
379
- }
380
- /**
381
- * Create a signed, time-limited URL that lets anyone download a single file
382
- * from this disk without authentication. The returned URL embeds a
383
- * cryptographically signed token carrying the disk, the file's key, and an
384
- * expiry — share it directly; no API key is needed to redeem it.
385
- *
386
- * @param key Path to the file on the disk (e.g. "reports/2026-01/data.pdf").
387
- * @param opts `expiresIn` sets the URL lifetime in seconds (any positive
388
- * integer, max 604800 = 7 days). Defaults to 24h.
389
- */
390
- async share(key, opts = {}) {
391
- const body = { key };
392
- if (opts.expiresIn !== void 0) body.expiresIn = opts.expiresIn;
393
- const call = this._client.POST;
394
- return unwrap(call(`/api/disks/${this.id}/share`, { body }));
395
- }
396
- /**
397
- * Read an object from the disk via the S3-compatible GetObject API and return
398
- * its full contents as bytes. Throws `ArchilS3Error` if the object does not
399
- * exist (status 404, code "NoSuchKey") or the request is rejected; use
400
- * `headObject`/`objectExists` to check existence without throwing.
401
- *
402
- * @param key Path on the disk (e.g. "reports/2026-01/data.json")
403
- */
404
- async getObject(key) {
405
- const resp = await this._s3Request("GET", key);
406
- if (!resp.ok) {
407
- throw parseS3Error("GetObject", resp.status, resp.statusText, decodeText(resp.body));
408
- }
409
- return resp.body;
410
- }
411
- /**
412
- * Fetch an object's metadata (size, etag, content type, last-modified) without
413
- * downloading its contents, via the S3-compatible HeadObject API. Returns
414
- * `null` if the object does not exist.
415
- *
416
- * @param key Path on the disk (e.g. "reports/2026-01/data.json")
417
- */
418
- async headObject(key) {
419
- const resp = await this._s3Request("HEAD", key);
420
- if (resp.status === 404) return null;
421
- if (!resp.ok) {
422
- throw parseS3Error("HeadObject", resp.status, resp.statusText, decodeText(resp.body));
423
- }
424
- const lastModified = resp.headers.get("last-modified");
425
- return {
426
- size: Number(resp.headers.get("content-length") ?? 0),
427
- etag: resp.headers.get("etag") ?? void 0,
428
- contentType: resp.headers.get("content-type") ?? void 0,
429
- lastModified: lastModified ? new Date(lastModified) : void 0
430
- };
431
- }
432
- /** Whether an object exists on the disk (a HeadObject that maps 404 → false). */
433
- async objectExists(key) {
434
- return await this.headObject(key) !== null;
435
- }
436
- /**
437
- * Write an object to the disk via the S3-compatible API. Handles any size:
438
- * bodies at or below `multipartThreshold` (defaults to `partSize`, i.e.
439
- * 16 MiB) go through a single PutObject request; larger bodies are uploaded as
440
- * a multipart upload — split into `partSize` parts, uploaded with bounded
441
- * `concurrency` (default 4), and assembled. A failed part aborts the upload so
442
- * nothing is left half-staged. For manual control over the multipart
443
- * lifecycle, use the {@link multipart} namespace.
444
- *
445
- * Faster than exec for large files — no container overhead, no command-length
446
- * limits. Returns the entity tag the server assigned (a multipart upload's tag
447
- * is S3's `md5(concat(partMd5s))-N` form rather than a plain MD5).
448
- *
449
- * @param key Path on the disk (e.g. "reports/2026-01/data.json")
450
- * @param body Contents as a string, Uint8Array/Buffer, or ArrayBuffer
451
- * @param options Either a content-type string, or {@link PutObjectOptions}
452
- * (`contentType`, `multipartThreshold`, `partSize`,
453
- * `concurrency`). Content type defaults to
454
- * "application/octet-stream".
455
- */
456
- async putObject(key, body, options) {
457
- const opts = typeof options === "string" ? { contentType: options } : options ?? {};
458
- const contentType = opts.contentType ?? "application/octet-stream";
459
- const partSize = Math.max(opts.partSize ?? DEFAULT_PART_SIZE, MIN_PART_SIZE);
460
- const threshold = opts.multipartThreshold ?? partSize;
461
- const bytes = toBytes(body);
462
- if (bytes.length <= threshold) {
463
- const resp = await this._s3Request("PUT", key, { body, contentType });
464
- if (!resp.ok) {
465
- throw parseS3Error("PutObject", resp.status, resp.statusText, decodeText(resp.body));
466
- }
467
- return { etag: resp.headers.get("etag") ?? void 0 };
468
- }
469
- return this._putMultipart(
470
- key,
471
- bytes,
472
- contentType,
473
- partSize,
474
- Math.max(1, opts.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY)
475
- );
476
- }
477
- /**
478
- * Upload a large body through the multipart lifecycle: split into `partSize`
479
- * parts, upload them with bounded concurrency, then complete aborting the
480
- * upload if any part fails so nothing is left half-staged. @internal
481
- */
482
- async _putMultipart(key, bytes, contentType, partSize, concurrency) {
483
- const effectivePartSize = effectiveUploadPartSize(bytes.length, partSize);
484
- const mp = this.multipart;
485
- const upload = await mp.create(key, contentType);
486
- try {
487
- const partCount = Math.ceil(bytes.length / effectivePartSize);
488
- const parts = new Array(partCount);
489
- let next = 0;
490
- const worker = async () => {
491
- for (; ; ) {
492
- const index = next++;
493
- if (index >= partCount) return;
494
- const start = index * effectivePartSize;
495
- const slice = bytes.subarray(start, Math.min(start + effectivePartSize, bytes.length));
496
- parts[index] = await mp.uploadPart(key, upload.uploadId, index + 1, slice);
497
- }
498
- };
499
- await Promise.all(Array.from({ length: Math.min(concurrency, partCount) }, worker));
500
- const done = await mp.complete(key, upload.uploadId, parts);
501
- return { etag: done.etag };
502
- } catch (err) {
503
- await mp.abort(key, upload.uploadId).catch(() => {
504
- });
505
- throw err;
506
- }
507
- }
508
- /**
509
- * Append bytes to an object via the S3-compatible PutObject append extension
510
- * (`?append=true`). If the object already exists the bytes are appended to it;
511
- * if it doesn't, it is created. Returns the entity tag of the full object
512
- * after the append.
513
- *
514
- * Each call may append at most 1 MiB — the server rejects a larger body with
515
- * `EntityTooLarge`. To grow an object past that, append in chunks (or use
516
- * {@link putObject} for a one-shot large write).
517
- *
518
- * Unlike most operations this is NOT auto-retried on a transient error:
519
- * append isn't idempotent, so retrying a succeeded-but-unacknowledged append
520
- * would duplicate the bytes. On a transient failure, re-append yourself only
521
- * after confirming the object's size.
522
- *
523
- * @param key Path on the disk (e.g. "logs/app.log")
524
- * @param body Bytes to append (string, Uint8Array/Buffer, or ArrayBuffer)
525
- * @param contentType MIME type, applied only when the object is newly created.
526
- */
527
- async appendObject(key, body, contentType = "application/octet-stream") {
528
- const resp = await this._s3Request("PUT", key, {
529
- body,
530
- contentType,
531
- query: { append: "true" },
532
- retry: false
533
- });
534
- if (!resp.ok) {
535
- throw parseS3Error("AppendObject", resp.status, resp.statusText, decodeText(resp.body));
536
- }
537
- return { etag: resp.headers.get("etag") ?? void 0 };
538
- }
539
- /**
540
- * Delete an object from the disk via the S3-compatible DeleteObject API.
541
- * Idempotent: deleting a key that doesn't exist resolves successfully, per
542
- * S3 semantics.
543
- *
544
- * @param key Path on the disk (e.g. "project/dist/server.cjs")
545
- */
546
- async deleteObject(key) {
547
- const resp = await this._s3Request("DELETE", key);
548
- if (!resp.ok && resp.status !== 404) {
549
- throw parseS3Error("DeleteObject", resp.status, resp.statusText, decodeText(resp.body));
550
- }
551
- }
552
- /**
553
- * List objects on the disk via the S3-compatible ListObjectsV2 API. By
554
- * default this follows continuation tokens until the listing is exhausted and
555
- * returns every matching key. Use `limit` to cap the total, `singlePage` for a
556
- * single request, or {@link listObjectsPages} to stream pages without loading
557
- * everything into memory.
558
- *
559
- * @param prefix Only return keys beginning with this prefix (omit for all).
560
- * @param opts Listing and pagination options.
561
- */
562
- async listObjects(prefix, opts = {}) {
563
- if (opts.singlePage) {
564
- return this._listObjectsPage(prefix, opts);
565
- }
566
- const objects = [];
567
- const commonPrefixes = [];
568
- const seenPrefixes = /* @__PURE__ */ new Set();
569
- let echoedPrefix;
570
- let truncated = false;
571
- outer: for await (const page of this.listObjectsPages(prefix, opts)) {
572
- echoedPrefix = page.prefix;
573
- for (const cp of page.commonPrefixes) {
574
- if (!seenPrefixes.has(cp)) {
575
- seenPrefixes.add(cp);
576
- commonPrefixes.push(cp);
577
- }
578
- }
579
- for (const obj of page.objects) {
580
- if (opts.limit !== void 0 && objects.length >= opts.limit) {
581
- truncated = true;
582
- break outer;
583
- }
584
- objects.push(obj);
585
- }
586
- }
587
- return { objects, commonPrefixes, isTruncated: truncated, keyCount: objects.length, prefix: echoedPrefix };
588
- }
589
- /**
590
- * Yield ListObjectsV2 pages lazily, following continuation tokens. A
591
- * memory-friendly way to process a large listing without materializing it:
592
- *
593
- * ```ts
594
- * for await (const page of disk.listObjectsPages("logs/")) {
595
- * for (const obj of page.objects) process(obj);
596
- * }
597
- * ```
598
- *
599
- * @param prefix Only return keys beginning with this prefix (omit for all).
600
- * @param opts Listing options (`limit` / `singlePage` are ignored here
601
- * control your own loop).
602
- */
603
- async *listObjectsPages(prefix, opts = {}) {
604
- const seenTokens = /* @__PURE__ */ new Set();
605
- let continuationToken = opts.continuationToken;
606
- for (; ; ) {
607
- const page = await this._listObjectsPage(prefix, { ...opts, continuationToken });
608
- yield page;
609
- const next = page.isTruncated ? page.nextContinuationToken : void 0;
610
- if (!next || seenTokens.has(next)) break;
611
- seenTokens.add(next);
612
- continuationToken = next;
613
- }
614
- }
615
- /** Fetch a single ListObjectsV2 page. @internal */
616
- async _listObjectsPage(prefix, opts) {
617
- const query = { "list-type": 2 };
618
- if (prefix !== void 0) query.prefix = prefix;
619
- if (!opts.recursive) query.delimiter = "/";
620
- if (opts.continuationToken !== void 0) query["continuation-token"] = opts.continuationToken;
621
- if (opts.startAfter !== void 0) query["start-after"] = opts.startAfter;
622
- const resp = await this._s3Request("GET", "", { query });
623
- if (!resp.ok) {
624
- throw parseS3Error("ListObjectsV2", resp.status, resp.statusText, decodeText(resp.body));
625
- }
626
- return parseListObjectsResult(decodeText(resp.body));
627
- }
628
- /**
629
- * Delete up to many objects in a single S3-compatible DeleteObjects request.
630
- * Unlike {@link deleteObject}, failures are reported per key rather than
631
- * thrown: the result's `deleted` lists the keys that were removed and
632
- * `errors` lists the ones that weren't (with the server's code/message). A
633
- * key that didn't exist still counts as deleted, per S3 semantics.
634
- *
635
- * The server caps a single request at 1000 keys; this method transparently
636
- * splits larger inputs into 1000-key batches and merges the results.
637
- *
638
- * @param keys Object keys to delete.
639
- * @param opts `quiet` suppresses the per-key success list server-side.
640
- */
641
- async deleteObjects(keys, opts = {}) {
642
- const deleted = [];
643
- const errors = [];
644
- for (let i = 0; i < keys.length; i += MAX_DELETE_OBJECTS_PER_REQUEST) {
645
- const batch = keys.slice(i, i + MAX_DELETE_OBJECTS_PER_REQUEST);
646
- const xml = buildDeleteObjectsXml(batch, opts.quiet ?? false);
647
- const resp = await this._s3Request("POST", "", {
648
- query: { delete: "" },
649
- body: xml,
650
- contentType: "application/xml"
651
- });
652
- if (!resp.ok) {
653
- throw parseS3Error("DeleteObjects", resp.status, resp.statusText, decodeText(resp.body));
654
- }
655
- const parsed = parseDeleteObjectsResult(decodeText(resp.body));
656
- deleted.push(...parsed.deleted);
657
- errors.push(...parsed.errors);
658
- }
659
- return { deleted, errors };
660
- }
661
- /**
662
- * The advanced, opt-in multipart-upload API. Drive the raw lifecycle
663
- * yourself `create` `uploadPart` → `complete` (or `abort`), plus
664
- * `listParts` / `listUploads`. Most callers don't need this: {@link putObject}
665
- * runs the whole lifecycle automatically for large bodies. Reach for it only
666
- * when you need manual control (e.g. uploading parts from separate processes),
667
- * and note you then own part-size, memory, and concurrency management.
668
- */
669
- get multipart() {
670
- return this._multipart ??= new DiskMultipart(this.id, this._s3Request.bind(this));
671
- }
672
- /**
673
- * Send a single request to the disk's S3-compatible endpoint. This reuses the
674
- * control-plane client purely for its credential and transport — the same
675
- * `Authorization` header is sent and verified by the same code server-side —
676
- * pointed at the S3 host. Returns the response status and fully-buffered body
677
- * so callers can inspect both regardless of the verb used.
678
- *
679
- * The S3 routes (`/{diskId}/{key}` for objects, `/{diskId}` for the bucket)
680
- * are not part of the typed control-plane API, so the path and per-request
681
- * options are passed untyped. An empty `key` targets the bucket itself (used
682
- * by listObjects).
683
- *
684
- * @internal
685
- */
686
- async _s3Request(method, key, opts = {}) {
687
- if (!this._s3BaseUrl) {
688
- throw new Error(
689
- "S3 base URL not configured. Pass s3BaseUrl to new Archil({...}) or set ARCHIL_S3_BASE_URL."
690
- );
691
- }
692
- const call = this._client[method];
693
- const trimmedKey = key.replace(/^\//, "");
694
- const encodedKey = trimmedKey.split("/").map(encodeURIComponent).join("/");
695
- const path = encodedKey ? `/${this.id}/${encodedKey}` : `/${this.id}`;
696
- const init = {
697
- baseUrl: this._s3BaseUrl,
698
- parseAs: "stream",
699
- ...opts.query ? { params: { query: opts.query } } : {},
700
- // fetch accepts string / Uint8Array / ArrayBuffer bodies directly; pass it
701
- // through unchanged (no Node Buffer in the data path). Bodies are fully
702
- // buffered (never streams), so re-sending one on a retry is safe.
703
- ...opts.body !== void 0 ? { body: opts.body, bodySerializer: (b) => b } : {},
704
- ...opts.contentType ? { headers: { "Content-Type": opts.contentType } } : {}
705
- };
706
- const hasBody = method === "GET" || method === "POST";
707
- const maxRetries = opts.retry === false ? 0 : MAX_S3_RETRIES;
708
- for (let attempt = 0; ; attempt++) {
709
- let error;
710
- let response;
711
- try {
712
- ({ error, response } = await call(path, init));
713
- } catch (transportError) {
714
- if (attempt < maxRetries) {
715
- await sleep(s3RetryDelayMs(attempt));
716
- continue;
717
- }
718
- throw transportError;
719
- }
720
- if (!response.ok) {
721
- if (isTransientS3Status(response.status) && attempt < maxRetries) {
722
- await response.body?.cancel().catch(() => {
723
- });
724
- await sleep(s3RetryDelayMs(attempt));
725
- continue;
726
- }
727
- const message = typeof error === "string" ? error : error ? JSON.stringify(error) : "";
728
- return {
729
- ok: false,
730
- status: response.status,
731
- statusText: response.statusText,
732
- headers: response.headers,
733
- body: new TextEncoder().encode(message)
734
- };
735
- }
736
- const bytes = hasBody ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array(0);
737
- return {
738
- ok: true,
739
- status: response.status,
740
- statusText: response.statusText,
741
- headers: response.headers,
742
- body: bytes
743
- };
744
- }
745
- }
746
- /**
747
- * Connect to this disk's data plane via the native ArchilClient.
748
- *
749
- * Requires the native module to be available (platform-specific .node binary).
750
- */
751
- async mount(opts) {
752
- let ArchilClient;
753
- try {
754
- const nativeSpecifier = "@archildata/native";
755
- const native = await import(nativeSpecifier);
756
- ArchilClient = native.ArchilClient ?? native.default?.ArchilClient;
757
- if (!ArchilClient) {
758
- throw new Error("@archildata/native did not export ArchilClient");
759
- }
760
- } catch {
761
- throw new Error(
762
- "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly. mount() is not supported in browsers."
763
- );
764
- }
765
- return ArchilClient.connect({
766
- region: this._archilRegion,
767
- diskName: `${this.organization}/${this.name}`,
768
- authToken: opts?.authToken,
769
- logLevel: opts?.logLevel,
770
- serverAddress: opts?.serverAddress,
771
- insecure: opts?.insecure
772
- });
773
- }
183
+ id;
184
+ name;
185
+ organization;
186
+ status;
187
+ provider;
188
+ region;
189
+ createdAt;
190
+ fsHandlerStatus;
191
+ lastAccessed;
192
+ dataSize;
193
+ monthlyUsage;
194
+ mounts;
195
+ metrics;
196
+ connectedClients;
197
+ authorizedUsers;
198
+ allowedIps;
199
+ /** @internal */
200
+ _client;
201
+ /** @internal */
202
+ _archilRegion;
203
+ /** Base URL for the S3-compatible API (port 9000 ingress). Empty if unset. */
204
+ _s3BaseUrl;
205
+ /** Lazily-constructed multipart namespace (see {@link multipart}). @internal */
206
+ _multipart;
207
+ /** @internal */
208
+ constructor(data, client, archilRegion, s3BaseUrl) {
209
+ this.id = data.id;
210
+ this.name = data.name;
211
+ this.organization = data.organization;
212
+ this.status = data.status;
213
+ this.provider = data.provider;
214
+ this.region = data.region;
215
+ this.createdAt = data.createdAt;
216
+ this.fsHandlerStatus = data.fsHandlerStatus;
217
+ this.lastAccessed = data.lastAccessed;
218
+ this.dataSize = data.dataSize;
219
+ this.monthlyUsage = data.monthlyUsage;
220
+ this.mounts = data.mounts;
221
+ this.metrics = data.metrics;
222
+ this.connectedClients = data.connectedClients;
223
+ this.authorizedUsers = data.authorizedUsers;
224
+ this.allowedIps = data.allowedIps;
225
+ this._client = client;
226
+ this._archilRegion = archilRegion;
227
+ this._s3BaseUrl = s3BaseUrl ?? "";
228
+ }
229
+ toJSON() {
230
+ return {
231
+ id: this.id,
232
+ name: this.name,
233
+ organization: this.organization,
234
+ status: this.status,
235
+ provider: this.provider,
236
+ region: this.region,
237
+ createdAt: this.createdAt,
238
+ fsHandlerStatus: this.fsHandlerStatus,
239
+ lastAccessed: this.lastAccessed,
240
+ dataSize: this.dataSize,
241
+ monthlyUsage: this.monthlyUsage,
242
+ mounts: this.mounts,
243
+ metrics: this.metrics,
244
+ connectedClients: this.connectedClients,
245
+ authorizedUsers: this.authorizedUsers,
246
+ allowedIps: this.allowedIps
247
+ };
248
+ }
249
+ async addUser(user) {
250
+ return unwrap(this._client.POST("/api/disks/{id}/users", {
251
+ params: { path: { id: this.id } },
252
+ body: user
253
+ }));
254
+ }
255
+ async removeUser(userType, identifier) {
256
+ await unwrapEmpty(this._client.DELETE("/api/disks/{id}/users/{userType}", { params: {
257
+ path: {
258
+ id: this.id,
259
+ userType
260
+ },
261
+ query: { identifier }
262
+ } }));
263
+ }
264
+ async createToken(nickname) {
265
+ const user = await unwrap(this._client.POST("/api/disks/{id}/users", {
266
+ params: { path: { id: this.id } },
267
+ body: {
268
+ type: "token",
269
+ nickname
270
+ }
271
+ }));
272
+ if (!user.token || !user.identifier) throw new Error("Server did not return a generated token");
273
+ return user;
274
+ }
275
+ async removeTokenUser(identifier) {
276
+ await this.removeUser("token", identifier);
277
+ }
278
+ async getAllowedIPs() {
279
+ return (await unwrap(this._client.GET("/api/disks/{id}/allowed-ips", { params: { path: { id: this.id } } }))).allowedIps;
280
+ }
281
+ async setAllowedIPs(allowedIps) {
282
+ return (await unwrap(this._client.PUT("/api/disks/{id}/allowed-ips", {
283
+ params: { path: { id: this.id } },
284
+ body: { allowedIps }
285
+ }))).allowedIps;
286
+ }
287
+ async addAllowedIP(ip) {
288
+ const current = await this.getAllowedIPs();
289
+ if (current.includes(ip)) return current;
290
+ return this.setAllowedIPs([...current, ip]);
291
+ }
292
+ async removeAllowedIP(ip) {
293
+ const current = await this.getAllowedIPs();
294
+ return this.setAllowedIPs(current.filter((i) => i !== ip));
295
+ }
296
+ async delete() {
297
+ await unwrapEmpty(this._client.DELETE("/api/disks/{id}", { params: { path: { id: this.id } } }));
298
+ }
299
+ /**
300
+ * Execute a command in a container with this disk mounted.
301
+ * Blocks until the command completes and returns stdout, stderr, and exit code.
302
+ */
303
+ async exec(command) {
304
+ return unwrap(this._client.POST("/api/disks/{id}/exec", {
305
+ params: { path: { id: this.id } },
306
+ body: { command }
307
+ }));
308
+ }
309
+ /**
310
+ * Constant-time parallel grep across files on this disk. Listing and
311
+ * matching are fanned out across ephemeral exec containers; the request
312
+ * finishes within the supplied time budget regardless of dataset size.
313
+ *
314
+ * The returned `stoppedReason` says whether the search ran to completion
315
+ * or short-circuited on `maxResults` / `maxDurationSeconds`. When
316
+ * stopping early, the matches returned are a sample (whichever workers
317
+ * reported first), not the lexicographically first N.
318
+ */
319
+ async grep(opts) {
320
+ return unwrap(this._client.POST("/api/disks/{id}/grep", {
321
+ params: { path: { id: this.id } },
322
+ body: {
323
+ directory: opts.directory,
324
+ pattern: opts.pattern,
325
+ recursive: opts.recursive ?? false,
326
+ maxDurationSeconds: opts.maxDurationSeconds ?? 30,
327
+ concurrency: opts.concurrency ?? 50,
328
+ maxResults: opts.maxResults ?? 1e3
329
+ }
330
+ }));
331
+ }
332
+ /**
333
+ * Create a signed, time-limited URL that lets anyone download a single file
334
+ * from this disk without authentication. The returned URL embeds a
335
+ * cryptographically signed token carrying the disk, the file's key, and an
336
+ * expiry — share it directly; no API key is needed to redeem it.
337
+ *
338
+ * @param key Path to the file on the disk (e.g. "reports/2026-01/data.pdf").
339
+ * @param opts `expiresIn` sets the URL lifetime in seconds (any positive
340
+ * integer, max 604800 = 7 days). Defaults to 24h.
341
+ */
342
+ async share(key, opts = {}) {
343
+ const body = { key };
344
+ if (opts.expiresIn !== void 0) body.expiresIn = opts.expiresIn;
345
+ const call = this._client.POST;
346
+ return unwrap(call(`/api/disks/${this.id}/share`, { body }));
347
+ }
348
+ /**
349
+ * Read an object from the disk via the S3-compatible GetObject API and return
350
+ * its full contents as bytes. Throws `ArchilS3Error` if the object does not
351
+ * exist (status 404, code "NoSuchKey") or the request is rejected; use
352
+ * `headObject`/`objectExists` to check existence without throwing.
353
+ *
354
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
355
+ */
356
+ async getObject(key) {
357
+ const resp = await this._s3Request("GET", key);
358
+ if (!resp.ok) throw parseS3Error("GetObject", resp.status, resp.statusText, decodeText(resp.body));
359
+ return resp.body;
360
+ }
361
+ /**
362
+ * Fetch an object's metadata (size, etag, content type, last-modified) without
363
+ * downloading its contents, via the S3-compatible HeadObject API. Returns
364
+ * `null` if the object does not exist.
365
+ *
366
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
367
+ */
368
+ async headObject(key) {
369
+ const resp = await this._s3Request("HEAD", key);
370
+ if (resp.status === 404) return null;
371
+ if (!resp.ok) throw parseS3Error("HeadObject", resp.status, resp.statusText, decodeText(resp.body));
372
+ const lastModified = resp.headers.get("last-modified");
373
+ return {
374
+ size: Number(resp.headers.get("content-length") ?? 0),
375
+ etag: resp.headers.get("etag") ?? void 0,
376
+ contentType: resp.headers.get("content-type") ?? void 0,
377
+ lastModified: lastModified ? new Date(lastModified) : void 0
378
+ };
379
+ }
380
+ /** Whether an object exists on the disk (a HeadObject that maps 404 → false). */
381
+ async objectExists(key) {
382
+ return await this.headObject(key) !== null;
383
+ }
384
+ /**
385
+ * Write an object to the disk via the S3-compatible API. Handles any size:
386
+ * bodies at or below `multipartThreshold` (defaults to `partSize`, i.e.
387
+ * 16 MiB) go through a single PutObject request; larger bodies are uploaded as
388
+ * a multipart upload split into `partSize` parts, uploaded with bounded
389
+ * `concurrency` (default 4), and assembled. A failed part aborts the upload so
390
+ * nothing is left half-staged. For manual control over the multipart
391
+ * lifecycle, use the {@link multipart} namespace.
392
+ *
393
+ * Faster than exec for large files — no container overhead, no command-length
394
+ * limits. Returns the entity tag the server assigned (a multipart upload's tag
395
+ * is S3's `md5(concat(partMd5s))-N` form rather than a plain MD5).
396
+ *
397
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
398
+ * @param body Contents as a string, Uint8Array/Buffer, or ArrayBuffer
399
+ * @param options Either a content-type string, or {@link PutObjectOptions}
400
+ * (`contentType`, `multipartThreshold`, `partSize`,
401
+ * `concurrency`). Content type defaults to
402
+ * "application/octet-stream".
403
+ */
404
+ async putObject(key, body, options) {
405
+ const opts = typeof options === "string" ? { contentType: options } : options ?? {};
406
+ const contentType = opts.contentType ?? "application/octet-stream";
407
+ const partSize = Math.max(opts.partSize ?? DEFAULT_PART_SIZE, MIN_PART_SIZE);
408
+ const threshold = opts.multipartThreshold ?? partSize;
409
+ const bytes = toBytes(body);
410
+ if (bytes.length <= threshold) {
411
+ const resp = await this._s3Request("PUT", key, {
412
+ body,
413
+ contentType
414
+ });
415
+ if (!resp.ok) throw parseS3Error("PutObject", resp.status, resp.statusText, decodeText(resp.body));
416
+ return { etag: resp.headers.get("etag") ?? void 0 };
417
+ }
418
+ return this._putMultipart(key, bytes, contentType, partSize, Math.max(1, opts.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY));
419
+ }
420
+ /**
421
+ * Upload a large body through the multipart lifecycle: split into `partSize`
422
+ * parts, upload them with bounded concurrency, then complete — aborting the
423
+ * upload if any part fails so nothing is left half-staged. @internal
424
+ */
425
+ async _putMultipart(key, bytes, contentType, partSize, concurrency) {
426
+ const effectivePartSize = effectiveUploadPartSize(bytes.length, partSize);
427
+ const mp = this.multipart;
428
+ const upload = await mp.create(key, contentType);
429
+ try {
430
+ const partCount = Math.ceil(bytes.length / effectivePartSize);
431
+ const parts = new Array(partCount);
432
+ let next = 0;
433
+ const worker = async () => {
434
+ for (;;) {
435
+ const index = next++;
436
+ if (index >= partCount) return;
437
+ const start = index * effectivePartSize;
438
+ const slice = bytes.subarray(start, Math.min(start + effectivePartSize, bytes.length));
439
+ parts[index] = await mp.uploadPart(key, upload.uploadId, index + 1, slice);
440
+ }
441
+ };
442
+ await Promise.all(Array.from({ length: Math.min(concurrency, partCount) }, worker));
443
+ return { etag: (await mp.complete(key, upload.uploadId, parts)).etag };
444
+ } catch (err) {
445
+ await mp.abort(key, upload.uploadId).catch(() => {});
446
+ throw err;
447
+ }
448
+ }
449
+ /**
450
+ * Append bytes to an object via the S3-compatible PutObject append extension
451
+ * (`?append=true`). If the object already exists the bytes are appended to it;
452
+ * if it doesn't, it is created. Returns the entity tag of the full object
453
+ * after the append.
454
+ *
455
+ * Each call may append at most 1 MiB — the server rejects a larger body with
456
+ * `EntityTooLarge`. To grow an object past that, append in chunks (or use
457
+ * {@link putObject} for a one-shot large write).
458
+ *
459
+ * Unlike most operations this is NOT auto-retried on a transient error:
460
+ * append isn't idempotent, so retrying a succeeded-but-unacknowledged append
461
+ * would duplicate the bytes. On a transient failure, re-append yourself only
462
+ * after confirming the object's size.
463
+ *
464
+ * @param key Path on the disk (e.g. "logs/app.log")
465
+ * @param body Bytes to append (string, Uint8Array/Buffer, or ArrayBuffer)
466
+ * @param contentType MIME type, applied only when the object is newly created.
467
+ */
468
+ async appendObject(key, body, contentType = "application/octet-stream") {
469
+ const resp = await this._s3Request("PUT", key, {
470
+ body,
471
+ contentType,
472
+ query: { append: "true" },
473
+ retry: false
474
+ });
475
+ if (!resp.ok) throw parseS3Error("AppendObject", resp.status, resp.statusText, decodeText(resp.body));
476
+ return { etag: resp.headers.get("etag") ?? void 0 };
477
+ }
478
+ /**
479
+ * Delete an object from the disk via the S3-compatible DeleteObject API.
480
+ * Idempotent: deleting a key that doesn't exist resolves successfully, per
481
+ * S3 semantics.
482
+ *
483
+ * @param key Path on the disk (e.g. "project/dist/server.cjs")
484
+ */
485
+ async deleteObject(key) {
486
+ const resp = await this._s3Request("DELETE", key);
487
+ if (!resp.ok && resp.status !== 404) throw parseS3Error("DeleteObject", resp.status, resp.statusText, decodeText(resp.body));
488
+ }
489
+ /**
490
+ * List objects on the disk via the S3-compatible ListObjectsV2 API. By
491
+ * default this follows continuation tokens until the listing is exhausted and
492
+ * returns every matching key. Use `limit` to cap the total, `singlePage` for a
493
+ * single request, or {@link listObjectsPages} to stream pages without loading
494
+ * everything into memory.
495
+ *
496
+ * @param prefix Only return keys beginning with this prefix (omit for all).
497
+ * @param opts Listing and pagination options.
498
+ */
499
+ async listObjects(prefix, opts = {}) {
500
+ if (opts.singlePage) return this._listObjectsPage(prefix, opts);
501
+ const objects = [];
502
+ const commonPrefixes = [];
503
+ const seenPrefixes = /* @__PURE__ */ new Set();
504
+ let echoedPrefix;
505
+ let truncated = false;
506
+ outer: for await (const page of this.listObjectsPages(prefix, opts)) {
507
+ echoedPrefix = page.prefix;
508
+ for (const cp of page.commonPrefixes) if (!seenPrefixes.has(cp)) {
509
+ seenPrefixes.add(cp);
510
+ commonPrefixes.push(cp);
511
+ }
512
+ for (const obj of page.objects) {
513
+ if (opts.limit !== void 0 && objects.length >= opts.limit) {
514
+ truncated = true;
515
+ break outer;
516
+ }
517
+ objects.push(obj);
518
+ }
519
+ }
520
+ return {
521
+ objects,
522
+ commonPrefixes,
523
+ isTruncated: truncated,
524
+ keyCount: objects.length,
525
+ prefix: echoedPrefix
526
+ };
527
+ }
528
+ /**
529
+ * Yield ListObjectsV2 pages lazily, following continuation tokens. A
530
+ * memory-friendly way to process a large listing without materializing it:
531
+ *
532
+ * ```ts
533
+ * for await (const page of disk.listObjectsPages("logs/")) {
534
+ * for (const obj of page.objects) process(obj);
535
+ * }
536
+ * ```
537
+ *
538
+ * @param prefix Only return keys beginning with this prefix (omit for all).
539
+ * @param opts Listing options (`limit` / `singlePage` are ignored here —
540
+ * control your own loop).
541
+ */
542
+ async *listObjectsPages(prefix, opts = {}) {
543
+ const seenTokens = /* @__PURE__ */ new Set();
544
+ let continuationToken = opts.continuationToken;
545
+ for (;;) {
546
+ const page = await this._listObjectsPage(prefix, {
547
+ ...opts,
548
+ continuationToken
549
+ });
550
+ yield page;
551
+ const next = page.isTruncated ? page.nextContinuationToken : void 0;
552
+ if (!next || seenTokens.has(next)) break;
553
+ seenTokens.add(next);
554
+ continuationToken = next;
555
+ }
556
+ }
557
+ /** Fetch a single ListObjectsV2 page. @internal */
558
+ async _listObjectsPage(prefix, opts) {
559
+ const query = { "list-type": 2 };
560
+ if (prefix !== void 0) query.prefix = prefix;
561
+ if (!opts.recursive) query.delimiter = "/";
562
+ if (opts.continuationToken !== void 0) query["continuation-token"] = opts.continuationToken;
563
+ if (opts.startAfter !== void 0) query["start-after"] = opts.startAfter;
564
+ const resp = await this._s3Request("GET", "", { query });
565
+ if (!resp.ok) throw parseS3Error("ListObjectsV2", resp.status, resp.statusText, decodeText(resp.body));
566
+ return parseListObjectsResult(decodeText(resp.body));
567
+ }
568
+ /**
569
+ * Delete up to many objects in a single S3-compatible DeleteObjects request.
570
+ * Unlike {@link deleteObject}, failures are reported per key rather than
571
+ * thrown: the result's `deleted` lists the keys that were removed and
572
+ * `errors` lists the ones that weren't (with the server's code/message). A
573
+ * key that didn't exist still counts as deleted, per S3 semantics.
574
+ *
575
+ * The server caps a single request at 1000 keys; this method transparently
576
+ * splits larger inputs into 1000-key batches and merges the results.
577
+ *
578
+ * @param keys Object keys to delete.
579
+ * @param opts `quiet` suppresses the per-key success list server-side.
580
+ */
581
+ async deleteObjects(keys, opts = {}) {
582
+ const deleted = [];
583
+ const errors = [];
584
+ for (let i = 0; i < keys.length; i += MAX_DELETE_OBJECTS_PER_REQUEST) {
585
+ const xml = buildDeleteObjectsXml(keys.slice(i, i + MAX_DELETE_OBJECTS_PER_REQUEST), opts.quiet ?? false);
586
+ const resp = await this._s3Request("POST", "", {
587
+ query: { delete: "" },
588
+ body: xml,
589
+ contentType: "application/xml"
590
+ });
591
+ if (!resp.ok) throw parseS3Error("DeleteObjects", resp.status, resp.statusText, decodeText(resp.body));
592
+ const parsed = parseDeleteObjectsResult(decodeText(resp.body));
593
+ deleted.push(...parsed.deleted);
594
+ errors.push(...parsed.errors);
595
+ }
596
+ return {
597
+ deleted,
598
+ errors
599
+ };
600
+ }
601
+ /**
602
+ * The advanced, opt-in multipart-upload API. Drive the raw lifecycle
603
+ * yourself `create` `uploadPart` `complete` (or `abort`), plus
604
+ * `listParts` / `listUploads`. Most callers don't need this: {@link putObject}
605
+ * runs the whole lifecycle automatically for large bodies. Reach for it only
606
+ * when you need manual control (e.g. uploading parts from separate processes),
607
+ * and note you then own part-size, memory, and concurrency management.
608
+ */
609
+ get multipart() {
610
+ return this._multipart ??= new DiskMultipart(this.id, this._s3Request.bind(this));
611
+ }
612
+ /**
613
+ * Send a single request to the disk's S3-compatible endpoint. This reuses the
614
+ * control-plane client purely for its credential and transport — the same
615
+ * `Authorization` header is sent and verified by the same code server-side —
616
+ * pointed at the S3 host. Returns the response status and fully-buffered body
617
+ * so callers can inspect both regardless of the verb used.
618
+ *
619
+ * The S3 routes (`/{diskId}/{key}` for objects, `/{diskId}` for the bucket)
620
+ * are not part of the typed control-plane API, so the path and per-request
621
+ * options are passed untyped. An empty `key` targets the bucket itself (used
622
+ * by listObjects).
623
+ *
624
+ * @internal
625
+ */
626
+ async _s3Request(method, key, opts = {}) {
627
+ if (!this._s3BaseUrl) throw new Error("S3 base URL not configured. Pass s3BaseUrl to new Archil({...}) or set ARCHIL_S3_BASE_URL.");
628
+ const call = this._client[method];
629
+ const encodedKey = key.replace(/^\//, "").split("/").map(encodeURIComponent).join("/");
630
+ const path = encodedKey ? `/${this.id}/${encodedKey}` : `/${this.id}`;
631
+ const init = {
632
+ baseUrl: this._s3BaseUrl,
633
+ parseAs: "stream",
634
+ ...opts.query ? { params: { query: opts.query } } : {},
635
+ ...opts.body !== void 0 ? {
636
+ body: opts.body,
637
+ bodySerializer: (b) => b
638
+ } : {},
639
+ ...opts.contentType ? { headers: { "Content-Type": opts.contentType } } : {}
640
+ };
641
+ const hasBody = method === "GET" || method === "POST";
642
+ const maxRetries = opts.retry === false ? 0 : MAX_S3_RETRIES;
643
+ for (let attempt = 0;; attempt++) {
644
+ let error;
645
+ let response;
646
+ try {
647
+ ({error, response} = await call(path, init));
648
+ } catch (transportError) {
649
+ if (attempt < maxRetries) {
650
+ await sleep(s3RetryDelayMs(attempt));
651
+ continue;
652
+ }
653
+ throw transportError;
654
+ }
655
+ if (!response.ok) {
656
+ if (isTransientS3Status(response.status) && attempt < maxRetries) {
657
+ await response.body?.cancel().catch(() => {});
658
+ await sleep(s3RetryDelayMs(attempt));
659
+ continue;
660
+ }
661
+ const message = typeof error === "string" ? error : error ? JSON.stringify(error) : "";
662
+ return {
663
+ ok: false,
664
+ status: response.status,
665
+ statusText: response.statusText,
666
+ headers: response.headers,
667
+ body: new TextEncoder().encode(message)
668
+ };
669
+ }
670
+ const bytes = hasBody ? new Uint8Array(await response.arrayBuffer()) : /* @__PURE__ */ new Uint8Array(0);
671
+ return {
672
+ ok: true,
673
+ status: response.status,
674
+ statusText: response.statusText,
675
+ headers: response.headers,
676
+ body: bytes
677
+ };
678
+ }
679
+ }
680
+ /**
681
+ * Connect to this disk's data plane via the native ArchilClient.
682
+ *
683
+ * Requires the native module to be available (platform-specific .node binary).
684
+ */
685
+ async mount(opts) {
686
+ let ArchilClient;
687
+ try {
688
+ const native = await import("@archildata/native");
689
+ ArchilClient = native.ArchilClient ?? native.default?.ArchilClient;
690
+ if (!ArchilClient) throw new Error("@archildata/native did not export ArchilClient");
691
+ } catch {
692
+ throw new Error("Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly. mount() is not supported in browsers.");
693
+ }
694
+ return ArchilClient.connect({
695
+ region: this._archilRegion,
696
+ diskName: `${this.organization}/${this.name}`,
697
+ authToken: opts?.authToken,
698
+ logLevel: opts?.logLevel,
699
+ serverAddress: opts?.serverAddress,
700
+ insecure: opts?.insecure
701
+ });
702
+ }
774
703
  };
704
+ /**
705
+ * The advanced, opt-in multipart-upload namespace, reached via {@link Disk.multipart}.
706
+ * Drives the raw S3 multipart lifecycle — `create` → `uploadPart` → `complete`
707
+ * (or `abort`), plus `listParts` / `listUploads`. Prefer {@link Disk.putObject},
708
+ * which runs this lifecycle automatically for large bodies; use this only when
709
+ * you need manual control, in which case you own part-size, memory, and
710
+ * concurrency management.
711
+ */
775
712
  var DiskMultipart = class {
776
- /** @internal */
777
- constructor(diskId, s3Request) {
778
- this.diskId = diskId;
779
- this.s3Request = s3Request;
780
- }
781
- diskId;
782
- s3Request;
783
- /**
784
- * Start a multipart upload (CreateMultipartUpload) and return its `uploadId`.
785
- *
786
- * @param key Path on the disk the finished object will live at.
787
- * @param contentType MIME type to store the object with.
788
- */
789
- async create(key, contentType) {
790
- const resp = await this.s3Request("POST", key, { query: { uploads: "" }, contentType });
791
- if (!resp.ok) {
792
- throw parseS3Error("CreateMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
793
- }
794
- const root = parseXml(decodeText(resp.body)).InitiateMultipartUploadResult ?? {};
795
- const uploadId = optionalString(root.UploadId);
796
- if (!uploadId) {
797
- throw new ArchilS3Error({
798
- operation: "CreateMultipartUpload",
799
- statusCode: resp.status,
800
- message: "response did not contain an UploadId",
801
- raw: decodeText(resp.body)
802
- });
803
- }
804
- return {
805
- uploadId,
806
- key: optionalString(root.Key) ?? key,
807
- bucket: optionalString(root.Bucket) ?? this.diskId
808
- };
809
- }
810
- /**
811
- * Upload one part (UploadPart) and return its entity tag, which you must
812
- * collect (with its part number) and pass to {@link complete}. Every part
813
- * except the last must be at least 5 MiB.
814
- *
815
- * @param key The upload's object key.
816
- * @param uploadId The id from {@link create}.
817
- * @param partNumber 1-based part number (1..=10000).
818
- * @param body Part contents as a string, Uint8Array/Buffer, or ArrayBuffer.
819
- */
820
- async uploadPart(key, uploadId, partNumber, body) {
821
- const resp = await this.s3Request("PUT", key, { query: { uploadId, partNumber }, body });
822
- if (!resp.ok) {
823
- throw parseS3Error("UploadPart", resp.status, resp.statusText, decodeText(resp.body));
824
- }
825
- return { partNumber, etag: resp.headers.get("etag") ?? "" };
826
- }
827
- /**
828
- * Finish a multipart upload (CompleteMultipartUpload), assembling the listed
829
- * parts into one object. Parts are sorted by part number before submission
830
- * (the server requires strictly-increasing order).
831
- *
832
- * Unlike the other operations this is NOT auto-retried on a transient error:
833
- * the gateway isn't idempotent for completion, so a retry after a
834
- * successful-but-unacknowledged complete would return a spurious NoSuchUpload.
835
- * On a transient failure, re-drive completion yourself only after confirming
836
- * the object isn't already present.
837
- *
838
- * @param key The upload's object key.
839
- * @param uploadId The id from {@link create}.
840
- * @param parts The `{ partNumber, etag }` pairs from {@link uploadPart}.
841
- */
842
- async complete(key, uploadId, parts) {
843
- const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
844
- const xml = buildCompleteMultipartUploadXml(sorted);
845
- const resp = await this.s3Request("POST", key, {
846
- query: { uploadId },
847
- body: xml,
848
- contentType: "application/xml",
849
- retry: false
850
- });
851
- if (!resp.ok) {
852
- throw parseS3Error("CompleteMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
853
- }
854
- const root = parseXml(decodeText(resp.body)).CompleteMultipartUploadResult ?? {};
855
- return {
856
- etag: optionalString(root.ETag),
857
- location: optionalString(root.Location),
858
- bucket: optionalString(root.Bucket),
859
- key: optionalString(root.Key)
860
- };
861
- }
862
- /**
863
- * Abort a multipart upload (AbortMultipartUpload), discarding every staged
864
- * part. Idempotent against an upload that's already gone (404 / NoSuchUpload
865
- * resolves successfully).
866
- *
867
- * @param key The upload's object key.
868
- * @param uploadId The id from {@link create}.
869
- */
870
- async abort(key, uploadId) {
871
- const resp = await this.s3Request("DELETE", key, { query: { uploadId } });
872
- if (!resp.ok && resp.status !== 404) {
873
- throw parseS3Error("AbortMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
874
- }
875
- }
876
- /**
877
- * List the parts already uploaded for an in-progress upload (ListParts).
878
- * Returns a single page; follow `nextPartNumberMarker` (when `isTruncated`)
879
- * to page through the rest.
880
- *
881
- * @param key The upload's object key.
882
- * @param uploadId The id from {@link create}.
883
- * @param opts `maxParts` / `partNumberMarker` pagination controls.
884
- */
885
- async listParts(key, uploadId, opts = {}) {
886
- const query = { uploadId };
887
- if (opts.maxParts !== void 0) query["max-parts"] = opts.maxParts;
888
- if (opts.partNumberMarker !== void 0) query["part-number-marker"] = opts.partNumberMarker;
889
- const resp = await this.s3Request("GET", key, { query });
890
- if (!resp.ok) {
891
- throw parseS3Error("ListParts", resp.status, resp.statusText, decodeText(resp.body));
892
- }
893
- return parseListPartsResult(decodeText(resp.body));
894
- }
895
- /**
896
- * List in-progress multipart uploads on the disk (ListMultipartUploads).
897
- * Returns a single page; follow `nextKeyMarker` / `nextUploadIdMarker` (when
898
- * `isTruncated`) for the rest.
899
- *
900
- * @param opts Prefix/delimiter filter and pagination markers.
901
- */
902
- async listUploads(opts = {}) {
903
- const query = { uploads: "" };
904
- if (opts.prefix !== void 0) query.prefix = opts.prefix;
905
- if (opts.delimiter !== void 0) query.delimiter = opts.delimiter;
906
- if (opts.keyMarker !== void 0) query["key-marker"] = opts.keyMarker;
907
- if (opts.uploadIdMarker !== void 0) query["upload-id-marker"] = opts.uploadIdMarker;
908
- if (opts.maxUploads !== void 0) query["max-uploads"] = opts.maxUploads;
909
- const resp = await this.s3Request("GET", "", { query });
910
- if (!resp.ok) {
911
- throw parseS3Error("ListMultipartUploads", resp.status, resp.statusText, decodeText(resp.body));
912
- }
913
- return parseListMultipartUploadsResult(decodeText(resp.body));
914
- }
713
+ diskId;
714
+ s3Request;
715
+ /** @internal */
716
+ constructor(diskId, s3Request) {
717
+ this.diskId = diskId;
718
+ this.s3Request = s3Request;
719
+ }
720
+ /**
721
+ * Start a multipart upload (CreateMultipartUpload) and return its `uploadId`.
722
+ *
723
+ * @param key Path on the disk the finished object will live at.
724
+ * @param contentType MIME type to store the object with.
725
+ */
726
+ async create(key, contentType) {
727
+ const resp = await this.s3Request("POST", key, {
728
+ query: { uploads: "" },
729
+ contentType
730
+ });
731
+ if (!resp.ok) throw parseS3Error("CreateMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
732
+ const root = parseXml(decodeText(resp.body)).InitiateMultipartUploadResult ?? {};
733
+ const uploadId = optionalString(root.UploadId);
734
+ if (!uploadId) throw new ArchilS3Error({
735
+ operation: "CreateMultipartUpload",
736
+ statusCode: resp.status,
737
+ message: "response did not contain an UploadId",
738
+ raw: decodeText(resp.body)
739
+ });
740
+ return {
741
+ uploadId,
742
+ key: optionalString(root.Key) ?? key,
743
+ bucket: optionalString(root.Bucket) ?? this.diskId
744
+ };
745
+ }
746
+ /**
747
+ * Upload one part (UploadPart) and return its entity tag, which you must
748
+ * collect (with its part number) and pass to {@link complete}. Every part
749
+ * except the last must be at least 5 MiB.
750
+ *
751
+ * @param key The upload's object key.
752
+ * @param uploadId The id from {@link create}.
753
+ * @param partNumber 1-based part number (1..=10000).
754
+ * @param body Part contents as a string, Uint8Array/Buffer, or ArrayBuffer.
755
+ */
756
+ async uploadPart(key, uploadId, partNumber, body) {
757
+ const resp = await this.s3Request("PUT", key, {
758
+ query: {
759
+ uploadId,
760
+ partNumber
761
+ },
762
+ body
763
+ });
764
+ if (!resp.ok) throw parseS3Error("UploadPart", resp.status, resp.statusText, decodeText(resp.body));
765
+ return {
766
+ partNumber,
767
+ etag: resp.headers.get("etag") ?? ""
768
+ };
769
+ }
770
+ /**
771
+ * Finish a multipart upload (CompleteMultipartUpload), assembling the listed
772
+ * parts into one object. Parts are sorted by part number before submission
773
+ * (the server requires strictly-increasing order).
774
+ *
775
+ * Unlike the other operations this is NOT auto-retried on a transient error:
776
+ * the gateway isn't idempotent for completion, so a retry after a
777
+ * successful-but-unacknowledged complete would return a spurious NoSuchUpload.
778
+ * On a transient failure, re-drive completion yourself only after confirming
779
+ * the object isn't already present.
780
+ *
781
+ * @param key The upload's object key.
782
+ * @param uploadId The id from {@link create}.
783
+ * @param parts The `{ partNumber, etag }` pairs from {@link uploadPart}.
784
+ */
785
+ async complete(key, uploadId, parts) {
786
+ const xml = buildCompleteMultipartUploadXml([...parts].sort((a, b) => a.partNumber - b.partNumber));
787
+ const resp = await this.s3Request("POST", key, {
788
+ query: { uploadId },
789
+ body: xml,
790
+ contentType: "application/xml",
791
+ retry: false
792
+ });
793
+ if (!resp.ok) throw parseS3Error("CompleteMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
794
+ const root = parseXml(decodeText(resp.body)).CompleteMultipartUploadResult ?? {};
795
+ return {
796
+ etag: optionalString(root.ETag),
797
+ location: optionalString(root.Location),
798
+ bucket: optionalString(root.Bucket),
799
+ key: optionalString(root.Key)
800
+ };
801
+ }
802
+ /**
803
+ * Abort a multipart upload (AbortMultipartUpload), discarding every staged
804
+ * part. Idempotent against an upload that's already gone (404 / NoSuchUpload
805
+ * resolves successfully).
806
+ *
807
+ * @param key The upload's object key.
808
+ * @param uploadId The id from {@link create}.
809
+ */
810
+ async abort(key, uploadId) {
811
+ const resp = await this.s3Request("DELETE", key, { query: { uploadId } });
812
+ if (!resp.ok && resp.status !== 404) throw parseS3Error("AbortMultipartUpload", resp.status, resp.statusText, decodeText(resp.body));
813
+ }
814
+ /**
815
+ * List the parts already uploaded for an in-progress upload (ListParts).
816
+ * Returns a single page; follow `nextPartNumberMarker` (when `isTruncated`)
817
+ * to page through the rest.
818
+ *
819
+ * @param key The upload's object key.
820
+ * @param uploadId The id from {@link create}.
821
+ * @param opts `maxParts` / `partNumberMarker` pagination controls.
822
+ */
823
+ async listParts(key, uploadId, opts = {}) {
824
+ const query = { uploadId };
825
+ if (opts.maxParts !== void 0) query["max-parts"] = opts.maxParts;
826
+ if (opts.partNumberMarker !== void 0) query["part-number-marker"] = opts.partNumberMarker;
827
+ const resp = await this.s3Request("GET", key, { query });
828
+ if (!resp.ok) throw parseS3Error("ListParts", resp.status, resp.statusText, decodeText(resp.body));
829
+ return parseListPartsResult(decodeText(resp.body));
830
+ }
831
+ /**
832
+ * List in-progress multipart uploads on the disk (ListMultipartUploads).
833
+ * Returns a single page; follow `nextKeyMarker` / `nextUploadIdMarker` (when
834
+ * `isTruncated`) for the rest.
835
+ *
836
+ * @param opts Prefix/delimiter filter and pagination markers.
837
+ */
838
+ async listUploads(opts = {}) {
839
+ const query = { uploads: "" };
840
+ if (opts.prefix !== void 0) query.prefix = opts.prefix;
841
+ if (opts.delimiter !== void 0) query.delimiter = opts.delimiter;
842
+ if (opts.keyMarker !== void 0) query["key-marker"] = opts.keyMarker;
843
+ if (opts.uploadIdMarker !== void 0) query["upload-id-marker"] = opts.uploadIdMarker;
844
+ if (opts.maxUploads !== void 0) query["max-uploads"] = opts.maxUploads;
845
+ const resp = await this.s3Request("GET", "", { query });
846
+ if (!resp.ok) throw parseS3Error("ListMultipartUploads", resp.status, resp.statusText, decodeText(resp.body));
847
+ return parseListMultipartUploadsResult(decodeText(resp.body));
848
+ }
915
849
  };
916
- var MAX_DELETE_OBJECTS_PER_REQUEST = 1e3;
917
- var MAX_S3_RETRIES = 3;
918
- var S3_RETRY_BASE_MS = 100;
919
- var S3_RETRY_CAP_MS = 2e3;
850
+ /** S3's per-request cap on DeleteObjects keys; larger inputs are batched. */
851
+ const MAX_DELETE_OBJECTS_PER_REQUEST = 1e3;
852
+ /** Automatic retries for a transient S3 failure (5xx / 429 / network error). */
853
+ const MAX_S3_RETRIES = 3;
854
+ /** Base backoff for S3 retries (ms); grows exponentially, then full-jittered. */
855
+ const S3_RETRY_BASE_MS = 100;
856
+ /** Ceiling for a single retry backoff (ms). */
857
+ const S3_RETRY_CAP_MS = 2e3;
858
+ /**
859
+ * Statuses worth retrying: throttling (429) and the gateway's transient 5xx
860
+ * (e.g. a journal-commit timeout surfaced as 500). 4xx other than 429 are
861
+ * caller errors and never retried.
862
+ */
920
863
  function isTransientS3Status(status) {
921
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
864
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
922
865
  }
866
+ /** Full-jittered exponential backoff for retry `attempt` (0-based). */
923
867
  function s3RetryDelayMs(attempt) {
924
- const ceiling = Math.min(S3_RETRY_CAP_MS, S3_RETRY_BASE_MS * 2 ** attempt);
925
- return Math.random() * ceiling;
868
+ const ceiling = Math.min(S3_RETRY_CAP_MS, S3_RETRY_BASE_MS * 2 ** attempt);
869
+ return Math.random() * ceiling;
926
870
  }
927
871
  function sleep(ms) {
928
- return new Promise((resolve) => setTimeout(resolve, ms));
872
+ return new Promise((resolve) => setTimeout(resolve, ms));
929
873
  }
930
- var MIN_PART_SIZE = 5 * 1024 * 1024;
931
- var DEFAULT_PART_SIZE = 16 * 1024 * 1024;
932
- var DEFAULT_UPLOAD_CONCURRENCY = 4;
933
- var MAX_PARTS_PER_UPLOAD = 1e4;
874
+ /** S3's minimum size for every multipart part but the last (5 MiB). */
875
+ const MIN_PART_SIZE = 5 * 1024 * 1024;
876
+ /** Default part size {@link Disk.putObject} uses on the multipart path (16 MiB). */
877
+ const DEFAULT_PART_SIZE = 16 * 1024 * 1024;
878
+ /** Default number of parts {@link Disk.putObject} uploads in parallel. */
879
+ const DEFAULT_UPLOAD_CONCURRENCY = 4;
880
+ /** The server's cap on parts in a single multipart upload (MAX_PARTS_PER_UPLOAD). */
881
+ const MAX_PARTS_PER_UPLOAD = 1e4;
882
+ /**
883
+ * Choose the part size for a `totalBytes` multipart upload. Returns
884
+ * `requestedPartSize` unless splitting at that size would exceed the server's
885
+ * 10,000-part cap, in which case it grows the part size (rounded up to a whole
886
+ * MiB) so the body fits in ≤ 10,000 parts — mirroring boto3's chunk-size
887
+ * adjustment. Parts only ever get larger, so they stay above the 5 MiB floor.
888
+ */
934
889
  function effectiveUploadPartSize(totalBytes, requestedPartSize) {
935
- if (Math.ceil(totalBytes / requestedPartSize) <= MAX_PARTS_PER_UPLOAD) {
936
- return requestedPartSize;
937
- }
938
- const MiB = 1024 * 1024;
939
- const needed = Math.ceil(totalBytes / MAX_PARTS_PER_UPLOAD);
940
- return Math.ceil(needed / MiB) * MiB;
890
+ if (Math.ceil(totalBytes / requestedPartSize) <= MAX_PARTS_PER_UPLOAD) return requestedPartSize;
891
+ const MiB = 1024 * 1024;
892
+ const needed = Math.ceil(totalBytes / MAX_PARTS_PER_UPLOAD);
893
+ return Math.ceil(needed / MiB) * MiB;
941
894
  }
895
+ /** Normalize an upload body to bytes so it can be sized and sliced into parts. */
942
896
  function toBytes(body) {
943
- if (typeof body === "string") return new TextEncoder().encode(body);
944
- if (body instanceof Uint8Array) return body;
945
- return new Uint8Array(body);
897
+ if (typeof body === "string") return new TextEncoder().encode(body);
898
+ if (body instanceof Uint8Array) return body;
899
+ return new Uint8Array(body);
946
900
  }
901
+ /** Escape text for safe inclusion in XML element content. */
947
902
  function escapeXml(value) {
948
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
903
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
949
904
  }
905
+ /** Build a `<Delete>` request body for the DeleteObjects API. */
950
906
  function buildDeleteObjectsXml(keys, quiet) {
951
- const objects = keys.map((k) => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("");
952
- const quietTag = quiet ? "<Quiet>true</Quiet>" : "";
953
- return `<?xml version="1.0" encoding="UTF-8"?><Delete>${objects}${quietTag}</Delete>`;
907
+ return `<?xml version="1.0" encoding="UTF-8"?><Delete>${keys.map((k) => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("")}${quiet ? "<Quiet>true</Quiet>" : ""}</Delete>`;
954
908
  }
909
+ /** Build a `<CompleteMultipartUpload>` request body from the uploaded parts. */
955
910
  function buildCompleteMultipartUploadXml(parts) {
956
- const body = parts.map(
957
- (p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`
958
- ).join("");
959
- return `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
911
+ return `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
960
912
  }
913
+ /** Coerce a parser value that may be a single object or an array into an array. */
961
914
  function asArray(value) {
962
- if (value === void 0 || value === null) return [];
963
- return Array.isArray(value) ? value : [value];
915
+ if (value === void 0 || value === null) return [];
916
+ return Array.isArray(value) ? value : [value];
964
917
  }
965
918
  function optionalNumber(value) {
966
- return value === void 0 || value === null ? void 0 : Number(value);
919
+ return value === void 0 || value === null ? void 0 : Number(value);
967
920
  }
968
921
  function optionalString(value) {
969
- return value === void 0 || value === null ? void 0 : String(value);
922
+ return value === void 0 || value === null ? void 0 : String(value);
970
923
  }
971
924
  function optionalDate(value) {
972
- return value === void 0 || value === null ? void 0 : new Date(String(value));
925
+ return value === void 0 || value === null ? void 0 : new Date(String(value));
973
926
  }
927
+ /** Decode response bytes to a UTF-8 string (used for XML error/result bodies). */
974
928
  function decodeText(bytes) {
975
- return new TextDecoder().decode(bytes);
929
+ return new TextDecoder().decode(bytes);
976
930
  }
931
+ /** Parse an S3 ListObjectsV2 `<ListBucketResult>` XML document. */
977
932
  function parseListObjectsResult(xml) {
978
- const root = parseXml(xml).ListBucketResult ?? {};
979
- const contents = root.Contents ?? [];
980
- const objects = contents.map((c) => ({
981
- key: String(c.Key ?? ""),
982
- size: Number(c.Size ?? 0),
983
- etag: optionalString(c.ETag),
984
- lastModified: optionalDate(c.LastModified)
985
- }));
986
- const commonPrefixes = (root.CommonPrefixes ?? []).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
987
- return {
988
- objects,
989
- commonPrefixes,
990
- isTruncated: root.IsTruncated === "true" || root.IsTruncated === true,
991
- nextContinuationToken: optionalString(root.NextContinuationToken),
992
- keyCount: root.KeyCount !== void 0 ? Number(root.KeyCount) : objects.length,
993
- prefix: optionalString(root.Prefix)
994
- };
933
+ const root = parseXml(xml).ListBucketResult ?? {};
934
+ const objects = (root.Contents ?? []).map((c) => ({
935
+ key: String(c.Key ?? ""),
936
+ size: Number(c.Size ?? 0),
937
+ etag: optionalString(c.ETag),
938
+ lastModified: optionalDate(c.LastModified)
939
+ }));
940
+ return {
941
+ objects,
942
+ commonPrefixes: (root.CommonPrefixes ?? []).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0),
943
+ isTruncated: root.IsTruncated === "true" || root.IsTruncated === true,
944
+ nextContinuationToken: optionalString(root.NextContinuationToken),
945
+ keyCount: root.KeyCount !== void 0 ? Number(root.KeyCount) : objects.length,
946
+ prefix: optionalString(root.Prefix)
947
+ };
995
948
  }
996
949
  function xmlIsTruncated(value) {
997
- return value === "true" || value === true;
950
+ return value === "true" || value === true;
998
951
  }
952
+ /** Parse an S3 DeleteObjects `<DeleteResult>` XML document. */
999
953
  function parseDeleteObjectsResult(xml) {
1000
- const root = parseXml(xml).DeleteResult ?? {};
1001
- const deleted = asArray(root.Deleted).map((d) => optionalString(d.Key)).filter((k) => k !== void 0);
1002
- const errors = asArray(root.Error).map((e) => ({
1003
- key: String(e.Key ?? ""),
1004
- code: optionalString(e.Code),
1005
- message: optionalString(e.Message)
1006
- }));
1007
- return { deleted, errors };
954
+ const root = parseXml(xml).DeleteResult ?? {};
955
+ return {
956
+ deleted: asArray(root.Deleted).map((d) => optionalString(d.Key)).filter((k) => k !== void 0),
957
+ errors: asArray(root.Error).map((e) => ({
958
+ key: String(e.Key ?? ""),
959
+ code: optionalString(e.Code),
960
+ message: optionalString(e.Message)
961
+ }))
962
+ };
1008
963
  }
964
+ /** Parse an S3 `<ListPartsResult>` XML document. */
1009
965
  function parseListPartsResult(xml) {
1010
- const root = parseXml(xml).ListPartsResult ?? {};
1011
- const parts = asArray(root.Part).map((p) => ({
1012
- partNumber: Number(p.PartNumber ?? 0),
1013
- etag: optionalString(p.ETag),
1014
- size: Number(p.Size ?? 0),
1015
- lastModified: optionalDate(p.LastModified)
1016
- }));
1017
- return {
1018
- bucket: optionalString(root.Bucket),
1019
- key: optionalString(root.Key),
1020
- uploadId: optionalString(root.UploadId),
1021
- parts,
1022
- isTruncated: xmlIsTruncated(root.IsTruncated),
1023
- partNumberMarker: Number(root.PartNumberMarker ?? 0),
1024
- nextPartNumberMarker: optionalNumber(root.NextPartNumberMarker),
1025
- maxParts: Number(root.MaxParts ?? parts.length)
1026
- };
966
+ const root = parseXml(xml).ListPartsResult ?? {};
967
+ const parts = asArray(root.Part).map((p) => ({
968
+ partNumber: Number(p.PartNumber ?? 0),
969
+ etag: optionalString(p.ETag),
970
+ size: Number(p.Size ?? 0),
971
+ lastModified: optionalDate(p.LastModified)
972
+ }));
973
+ return {
974
+ bucket: optionalString(root.Bucket),
975
+ key: optionalString(root.Key),
976
+ uploadId: optionalString(root.UploadId),
977
+ parts,
978
+ isTruncated: xmlIsTruncated(root.IsTruncated),
979
+ partNumberMarker: Number(root.PartNumberMarker ?? 0),
980
+ nextPartNumberMarker: optionalNumber(root.NextPartNumberMarker),
981
+ maxParts: Number(root.MaxParts ?? parts.length)
982
+ };
1027
983
  }
984
+ /** Parse an S3 `<ListMultipartUploadsResult>` XML document. */
1028
985
  function parseListMultipartUploadsResult(xml) {
1029
- const root = parseXml(xml).ListMultipartUploadsResult ?? {};
1030
- const uploads = asArray(root.Upload).map((u) => ({
1031
- key: String(u.Key ?? ""),
1032
- uploadId: String(u.UploadId ?? ""),
1033
- initiated: optionalDate(u.Initiated)
1034
- }));
1035
- const commonPrefixes = asArray(root.CommonPrefixes).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
1036
- return {
1037
- bucket: optionalString(root.Bucket),
1038
- uploads,
1039
- commonPrefixes,
1040
- isTruncated: xmlIsTruncated(root.IsTruncated),
1041
- keyMarker: optionalString(root.KeyMarker),
1042
- uploadIdMarker: optionalString(root.UploadIdMarker),
1043
- nextKeyMarker: optionalString(root.NextKeyMarker),
1044
- nextUploadIdMarker: optionalString(root.NextUploadIdMarker),
1045
- prefix: optionalString(root.Prefix),
1046
- delimiter: optionalString(root.Delimiter),
1047
- maxUploads: optionalNumber(root.MaxUploads)
1048
- };
986
+ const root = parseXml(xml).ListMultipartUploadsResult ?? {};
987
+ const uploads = asArray(root.Upload).map((u) => ({
988
+ key: String(u.Key ?? ""),
989
+ uploadId: String(u.UploadId ?? ""),
990
+ initiated: optionalDate(u.Initiated)
991
+ }));
992
+ const commonPrefixes = asArray(root.CommonPrefixes).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
993
+ return {
994
+ bucket: optionalString(root.Bucket),
995
+ uploads,
996
+ commonPrefixes,
997
+ isTruncated: xmlIsTruncated(root.IsTruncated),
998
+ keyMarker: optionalString(root.KeyMarker),
999
+ uploadIdMarker: optionalString(root.UploadIdMarker),
1000
+ nextKeyMarker: optionalString(root.NextKeyMarker),
1001
+ nextUploadIdMarker: optionalString(root.NextUploadIdMarker),
1002
+ prefix: optionalString(root.Prefix),
1003
+ delimiter: optionalString(root.Delimiter),
1004
+ maxUploads: optionalNumber(root.MaxUploads)
1005
+ };
1049
1006
  }
1050
-
1051
- // src/disks.ts
1007
+ //#endregion
1008
+ //#region src/disks.ts
1009
+ /** Server-side maximum page size for GET /api/disks. */
1010
+ const DISK_PAGE_LIMIT = 100;
1052
1011
  var Disks = class {
1053
- /** @internal */
1054
- _client;
1055
- /** @internal */
1056
- _region;
1057
- /** @internal */
1058
- _s3BaseUrl;
1059
- /** @internal */
1060
- constructor(client, region, s3BaseUrl) {
1061
- this._client = client;
1062
- this._region = region;
1063
- this._s3BaseUrl = s3BaseUrl;
1064
- }
1065
- async list(opts) {
1066
- const data = await unwrap(
1067
- this._client.GET("/api/disks", {
1068
- params: { query: { limit: opts?.limit, cursor: opts?.cursor, name: opts?.name } }
1069
- })
1070
- );
1071
- return data.map(
1072
- (d) => new Disk(d, this._client, this._region, this._s3BaseUrl)
1073
- );
1074
- }
1075
- async get(id) {
1076
- const data = await unwrap(
1077
- this._client.GET("/api/disks/{id}", {
1078
- params: { path: { id } }
1079
- })
1080
- );
1081
- return new Disk(data, this._client, this._region, this._s3BaseUrl);
1082
- }
1083
- /**
1084
- * Create a new disk with an auto-generated mount token.
1085
- *
1086
- * Returns the Disk, the one-time token (save it — it cannot be retrieved
1087
- * again), and the token identifier for later management.
1088
- */
1089
- async create(req) {
1090
- const created = await unwrap(
1091
- this._client.POST("/api/disks", { body: req })
1092
- );
1093
- const resp = created;
1094
- if (!resp.diskId) {
1095
- throw new Error("API returned success but no diskId");
1096
- }
1097
- const authorizedUsers = resp.authorizedUsers ?? [];
1098
- const tokenUser = authorizedUsers.find((u) => u.token);
1099
- const disk = await this.get(resp.diskId);
1100
- return {
1101
- disk,
1102
- token: tokenUser?.token ?? null,
1103
- tokenIdentifier: tokenUser?.identifier ?? null,
1104
- authorizedUsers
1105
- };
1106
- }
1012
+ /** @internal */
1013
+ _client;
1014
+ /** @internal */
1015
+ _region;
1016
+ /** @internal */
1017
+ _s3BaseUrl;
1018
+ /** @internal */
1019
+ constructor(client, region, s3BaseUrl) {
1020
+ this._client = client;
1021
+ this._region = region;
1022
+ this._s3BaseUrl = s3BaseUrl;
1023
+ }
1024
+ /**
1025
+ * List the account's disks. Fetches in cursor-driven pages (bounded server
1026
+ * work per request) and follows `nextCursor` until exhausted, so the result
1027
+ * is complete even for very large accounts. Use `limit` to cap the total, or
1028
+ * {@link listPage} to walk pages yourself.
1029
+ */
1030
+ async list(opts) {
1031
+ if (opts?.name !== void 0) return (await this.listPage(opts)).disks;
1032
+ const limit = opts?.limit;
1033
+ let cursor = opts?.cursor;
1034
+ const disks = [];
1035
+ for (;;) {
1036
+ const remaining = limit === void 0 ? void 0 : limit - disks.length;
1037
+ if (remaining !== void 0 && remaining <= 0) return disks;
1038
+ const pageLimit = remaining === void 0 ? DISK_PAGE_LIMIT : Math.min(remaining, DISK_PAGE_LIMIT);
1039
+ const page = await this.listPage({
1040
+ limit: pageLimit,
1041
+ cursor
1042
+ });
1043
+ disks.push(...remaining === void 0 ? page.disks : page.disks.slice(0, remaining));
1044
+ if (!page.nextCursor || page.nextCursor === cursor) return disks;
1045
+ cursor = page.nextCursor;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Fetch a single page of disks. `nextCursor` on the result resumes the
1050
+ * listing (it can also be persisted, e.g. across requests of a paginated UI).
1051
+ */
1052
+ async listPage(opts) {
1053
+ const { data, nextCursor } = await unwrapPage(this._client.GET("/api/disks", { params: { query: {
1054
+ limit: opts?.limit,
1055
+ cursor: opts?.cursor,
1056
+ name: opts?.name
1057
+ } } }));
1058
+ const disks = (data ?? []).map((d) => new Disk(d, this._client, this._region, this._s3BaseUrl));
1059
+ return nextCursor ? {
1060
+ disks,
1061
+ nextCursor
1062
+ } : { disks };
1063
+ }
1064
+ async get(id) {
1065
+ return new Disk(await unwrap(this._client.GET("/api/disks/{id}", { params: { path: { id } } })), this._client, this._region, this._s3BaseUrl);
1066
+ }
1067
+ /**
1068
+ * Create a new disk with an auto-generated mount token.
1069
+ *
1070
+ * Returns the Disk, the one-time token (save it — it cannot be retrieved
1071
+ * again), and the token identifier for later management.
1072
+ */
1073
+ async create(req) {
1074
+ const resp = await unwrap(this._client.POST("/api/disks", { body: req }));
1075
+ if (!resp.diskId) throw new Error("API returned success but no diskId");
1076
+ const authorizedUsers = resp.authorizedUsers ?? [];
1077
+ const tokenUser = authorizedUsers.find((u) => u.token);
1078
+ return {
1079
+ disk: await this.get(resp.diskId),
1080
+ token: tokenUser?.token ?? null,
1081
+ tokenIdentifier: tokenUser?.identifier ?? null,
1082
+ authorizedUsers
1083
+ };
1084
+ }
1107
1085
  };
1108
-
1109
- // src/tokens.ts
1086
+ //#endregion
1087
+ //#region src/tokens.ts
1110
1088
  var Tokens = class {
1111
- /** @internal */
1112
- _client;
1113
- /** @internal */
1114
- constructor(client) {
1115
- this._client = client;
1116
- }
1117
- async list(opts) {
1118
- const data = await unwrap(
1119
- this._client.GET("/api/tokens", {
1120
- params: { query: { limit: opts?.limit, cursor: opts?.cursor } }
1121
- })
1122
- );
1123
- return data.tokens ?? [];
1124
- }
1125
- async create(req) {
1126
- const data = await unwrap(
1127
- this._client.POST("/api/tokens", { body: req })
1128
- );
1129
- return data;
1130
- }
1131
- async delete(id) {
1132
- await unwrapEmpty(
1133
- this._client.DELETE("/api/tokens/{id}", {
1134
- params: { path: { id } }
1135
- })
1136
- );
1137
- }
1089
+ /** @internal */
1090
+ _client;
1091
+ /** @internal */
1092
+ constructor(client) {
1093
+ this._client = client;
1094
+ }
1095
+ async list(opts) {
1096
+ return (await unwrap(this._client.GET("/api/tokens", { params: { query: {
1097
+ limit: opts?.limit,
1098
+ cursor: opts?.cursor
1099
+ } } }))).tokens ?? [];
1100
+ }
1101
+ async create(req) {
1102
+ return await unwrap(this._client.POST("/api/tokens", { body: req }));
1103
+ }
1104
+ async delete(id) {
1105
+ await unwrapEmpty(this._client.DELETE("/api/tokens/{id}", { params: { path: { id } } }));
1106
+ }
1138
1107
  };
1139
-
1140
- // src/archil.ts
1108
+ //#endregion
1109
+ //#region src/workspace.ts
1110
+ function isExecMountSpec$1(m) {
1111
+ return typeof m === "object" && m !== null && "disk" in m;
1112
+ }
1113
+ /**
1114
+ * A key-routed filesystem spanning several Archil disks. Each disk is mounted
1115
+ * under a name and addressed as the first segment of a key (`<name>/...`);
1116
+ * `getObject`, `putObject`, `deleteObject`, `listObjects`, and `grep` route to
1117
+ * the right disk by that segment (and `listObjects` / `grep` fan out across all
1118
+ * of them when the key/prefix doesn't name one). Implements the same
1119
+ * {@link FileSystem} surface as a single {@link Disk}, so it works anywhere a
1120
+ * disk does. Mounts can be changed at runtime with {@link addDisk} /
1121
+ * {@link removeDisk}.
1122
+ */
1123
+ var Workspace = class {
1124
+ client;
1125
+ mounts = /* @__PURE__ */ new Map();
1126
+ /** @internal Use `archil.workspace({ ... })`. */
1127
+ constructor(client, mounts) {
1128
+ this.client = client;
1129
+ for (const [name, value] of Object.entries(mounts)) this.addDisk(name, value);
1130
+ if (this.mounts.size === 0) throw new Error("workspace() needs at least one disk");
1131
+ }
1132
+ /** Mount (or replace) a disk at `name`; its objects are addressed as
1133
+ * `<name>/...`. Accepts a `Disk` or a mount spec (read-only / subdirectory /
1134
+ * conditional / delegation checkouts); a bare disk-id string is rejected —
1135
+ * fetch the disk first. */
1136
+ addDisk(name, disk) {
1137
+ const rel = name.replace(/^\/+|\/+$/g, "");
1138
+ if (!rel) throw new Error("workspace mount name must be non-empty");
1139
+ if (rel.includes("/")) throw new Error(`workspace mount name '${name}' must not contain '/'`);
1140
+ if (rel === "." || rel === "..") throw new Error(`workspace mount name '${name}' is reserved ('.' and '..' are path navigation, not disk names)`);
1141
+ let d;
1142
+ let readOnly = false;
1143
+ let subdirectory;
1144
+ let conditional = false;
1145
+ let queueMs;
1146
+ let checkoutPaths;
1147
+ if (isExecMountSpec$1(disk)) {
1148
+ d = disk.disk;
1149
+ readOnly = disk.readOnly ?? false;
1150
+ subdirectory = disk.subdirectory;
1151
+ conditional = disk.conditional ?? false;
1152
+ queueMs = disk.queueMs;
1153
+ checkoutPaths = disk.checkoutPaths;
1154
+ } else d = disk;
1155
+ if (typeof d === "string") throw new Error("workspace needs Disk objects, not disk-id strings; fetch with archil.getDisk(id) first");
1156
+ this.mounts.set(rel, {
1157
+ disk: d,
1158
+ readOnly,
1159
+ subdirectory,
1160
+ conditional,
1161
+ queueMs,
1162
+ checkoutPaths
1163
+ });
1164
+ return this;
1165
+ }
1166
+ /** Unmount the disk at `name`. Returns whether a disk was removed. Refuses to
1167
+ * remove the last disk — a workspace must always have at least one (the same
1168
+ * invariant the constructor enforces), else fan-out/exec would have nothing to
1169
+ * route to. */
1170
+ removeDisk(name) {
1171
+ const rel = name.replace(/^\/+|\/+$/g, "");
1172
+ if (this.mounts.has(rel) && this.mounts.size === 1) throw new Error("cannot remove the last disk from a workspace; a workspace must keep at least one");
1173
+ return this.mounts.delete(rel);
1174
+ }
1175
+ /** The names of the currently-mounted disks. */
1176
+ diskNames() {
1177
+ return [...this.mounts.keys()];
1178
+ }
1179
+ unknownDisk(name) {
1180
+ return /* @__PURE__ */ new Error(`No disk named '${name}'. Address objects by disk: ${this.diskNames().map((n) => `${n}/…`).join(", ")}`);
1181
+ }
1182
+ /** Resolve a workspace key (`<name>/...`) to the disk and the disk-relative key. */
1183
+ route(key) {
1184
+ const segs = require_paths.toSegments(key);
1185
+ if (segs.length === 0) throw new Error("that key is the workspace root, not an object; name a disk, e.g. data/file.txt");
1186
+ const entry = this.mounts.get(segs[0]);
1187
+ if (!entry) throw this.unknownDisk(segs[0]);
1188
+ return {
1189
+ entry,
1190
+ diskKey: this.diskKey(entry, segs.slice(1).join("/"))
1191
+ };
1192
+ }
1193
+ /** Mounts touched by a key prefix; an empty prefix fans out to all of them. */
1194
+ covered(prefix) {
1195
+ const segs = require_paths.toSegments(prefix);
1196
+ if (segs.length === 0) return [...this.mounts.entries()].map(([name, entry]) => ({
1197
+ name,
1198
+ entry,
1199
+ rel: this.diskKey(entry, "")
1200
+ }));
1201
+ const entry = this.mounts.get(segs[0]);
1202
+ if (!entry) throw this.unknownDisk(segs[0]);
1203
+ return [{
1204
+ name: segs[0],
1205
+ entry,
1206
+ rel: this.diskKey(entry, segs.slice(1).join("/"))
1207
+ }];
1208
+ }
1209
+ /** Map a workspace-relative key to the disk's key, applying the mount's subdirectory. */
1210
+ diskKey(entry, rel) {
1211
+ const trimmed = rel.replace(/^\/+/, "");
1212
+ const sub = (entry.subdirectory ?? "").replace(/^\/+|\/+$/g, "");
1213
+ if (!sub) return trimmed;
1214
+ return trimmed ? `${sub}/${trimmed}` : sub;
1215
+ }
1216
+ /** Map a disk key back to its workspace key (`<name>/...`), stripping the mount subdirectory. */
1217
+ abs(name, entry, diskKey) {
1218
+ const sub = (entry.subdirectory ?? "").replace(/^\/+|\/+$/g, "");
1219
+ let rel = diskKey;
1220
+ if (sub && diskKey.startsWith(sub + "/")) rel = diskKey.slice(sub.length + 1);
1221
+ else if (sub && diskKey === sub) rel = "";
1222
+ rel = rel.replace(/^\/+|\/+$/g, "");
1223
+ return rel ? `${name}/${rel}` : name;
1224
+ }
1225
+ async getObject(key) {
1226
+ const { entry, diskKey } = this.route(key);
1227
+ return entry.disk.getObject(diskKey);
1228
+ }
1229
+ async putObject(key, body, contentType) {
1230
+ const { entry, diskKey } = this.route(key);
1231
+ if (entry.readOnly) throw new Error(`${key} is on a read-only disk and cannot be written.`);
1232
+ return entry.disk.putObject(diskKey, body, contentType);
1233
+ }
1234
+ async deleteObject(key) {
1235
+ const { entry, diskKey } = this.route(key);
1236
+ if (entry.readOnly) throw new Error(`${key} is on a read-only disk and cannot be deleted.`);
1237
+ await entry.disk.deleteObject(diskKey);
1238
+ }
1239
+ async listObjects(prefix, opts = {}) {
1240
+ if (require_paths.toSegments(prefix ?? "").length === 0 && !opts.recursive) return {
1241
+ objects: [],
1242
+ commonPrefixes: this.diskNames().map((n) => `${n}/`).sort(),
1243
+ isTruncated: false,
1244
+ keyCount: 0
1245
+ };
1246
+ const covered = this.covered(prefix ?? "");
1247
+ const listings = await Promise.allSettled(covered.map(({ entry, rel }) => {
1248
+ const p = rel ? rel.endsWith("/") ? rel : `${rel}/` : void 0;
1249
+ return entry.disk.listObjects(p, { recursive: opts.recursive });
1250
+ }));
1251
+ const objects = [];
1252
+ const commonPrefixes = [];
1253
+ let isTruncated = false;
1254
+ let keyCount = 0;
1255
+ covered.forEach(({ name, entry }, i) => {
1256
+ const settled = listings[i];
1257
+ if (settled.status === "rejected") {
1258
+ isTruncated = true;
1259
+ return;
1260
+ }
1261
+ const listing = settled.value;
1262
+ for (const cp of listing.commonPrefixes) commonPrefixes.push(`${this.abs(name, entry, cp.replace(/\/+$/, ""))}/`);
1263
+ for (const obj of listing.objects) objects.push({
1264
+ ...obj,
1265
+ key: this.abs(name, entry, obj.key)
1266
+ });
1267
+ isTruncated = isTruncated || listing.isTruncated;
1268
+ keyCount += listing.keyCount;
1269
+ });
1270
+ objects.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
1271
+ commonPrefixes.sort();
1272
+ return {
1273
+ objects,
1274
+ commonPrefixes,
1275
+ isTruncated,
1276
+ keyCount
1277
+ };
1278
+ }
1279
+ async grep(opts) {
1280
+ const covered = this.covered(opts.directory ?? "");
1281
+ const results = await Promise.allSettled(covered.map(({ entry, rel }) => entry.disk.grep({
1282
+ ...opts,
1283
+ directory: rel
1284
+ })));
1285
+ const matches = [];
1286
+ let filesScanned = 0;
1287
+ let containersDispatched = 0;
1288
+ let computeSecondsUsed = 0;
1289
+ let durationMs = 0;
1290
+ let listingMs = 0;
1291
+ let grepMs = 0;
1292
+ const reasons = /* @__PURE__ */ new Set();
1293
+ covered.forEach(({ name, entry }, i) => {
1294
+ const settled = results[i];
1295
+ if (settled.status === "rejected") {
1296
+ reasons.add("list_failed");
1297
+ return;
1298
+ }
1299
+ const r = settled.value;
1300
+ filesScanned += r.filesScanned;
1301
+ containersDispatched += r.containersDispatched ?? 0;
1302
+ computeSecondsUsed += r.computeSecondsUsed ?? 0;
1303
+ durationMs = Math.max(durationMs, r.durationMs ?? 0);
1304
+ listingMs = Math.max(listingMs, r.listingMs ?? 0);
1305
+ grepMs = Math.max(grepMs, r.grepMs ?? 0);
1306
+ reasons.add(r.stoppedReason);
1307
+ for (const m of r.matches) matches.push({
1308
+ file: this.abs(name, entry, m.file),
1309
+ line: m.line,
1310
+ text: m.text
1311
+ });
1312
+ });
1313
+ const cap = opts.maxResults ?? 1e3;
1314
+ if (matches.length > cap) reasons.add("max_results");
1315
+ const priority = [
1316
+ "list_failed",
1317
+ "incomplete",
1318
+ "deadline",
1319
+ "max_results"
1320
+ ];
1321
+ const stoppedReason = [...reasons].every((x) => x === "completed") ? "completed" : priority.find((x) => reasons.has(x)) ?? "completed";
1322
+ return {
1323
+ matches: matches.slice(0, cap),
1324
+ stoppedReason,
1325
+ filesScanned,
1326
+ containersDispatched,
1327
+ computeSecondsUsed,
1328
+ durationMs,
1329
+ listingMs,
1330
+ grepMs
1331
+ };
1332
+ }
1333
+ exec(command) {
1334
+ const disks = {};
1335
+ for (const [name, entry] of this.mounts) disks[name] = entry.readOnly || entry.subdirectory || entry.conditional || entry.queueMs !== void 0 || entry.checkoutPaths !== void 0 ? {
1336
+ disk: entry.disk,
1337
+ subdirectory: entry.subdirectory,
1338
+ readOnly: entry.readOnly,
1339
+ conditional: entry.conditional,
1340
+ queueMs: entry.queueMs,
1341
+ checkoutPaths: entry.checkoutPaths
1342
+ } : entry.disk;
1343
+ return this.client.exec({
1344
+ disks,
1345
+ command
1346
+ });
1347
+ }
1348
+ };
1349
+ //#endregion
1350
+ //#region src/archil.ts
1351
+ /**
1352
+ * Read an environment variable in a way that is safe in browsers, where the
1353
+ * `process` global does not exist. Returns undefined when there is no process
1354
+ * environment, so the SDK can be bundled and run client-side as long as
1355
+ * credentials are passed explicitly to `new Archil({ ... })`.
1356
+ */
1141
1357
  function envVar(name) {
1142
- return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
1358
+ return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
1143
1359
  }
1144
1360
  function isExecMountSpec(m) {
1145
- return typeof m === "object" && m !== null && "disk" in m;
1361
+ return typeof m === "object" && m !== null && "disk" in m;
1146
1362
  }
1147
1363
  function diskIdFromMount(m) {
1148
- return typeof m === "string" ? m : m.id;
1364
+ return typeof m === "string" ? m : m.id;
1149
1365
  }
1150
1366
  var Archil = class {
1151
- disks;
1152
- tokens;
1153
- /** @internal */
1154
- _client;
1155
- constructor(opts = {}) {
1156
- const apiKey = opts.apiKey ?? envVar("ARCHIL_API_KEY");
1157
- const region = opts.region ?? envVar("ARCHIL_REGION");
1158
- if (!apiKey) {
1159
- throw new Error("Missing API key: pass apiKey in options or set ARCHIL_API_KEY environment variable");
1160
- }
1161
- if (!region) {
1162
- throw new Error("Missing region: pass region in options or set ARCHIL_REGION environment variable");
1163
- }
1164
- const controlBaseUrl = opts.baseUrl ?? resolveBaseUrl(region);
1165
- const client = createApiClient({
1166
- apiKey,
1167
- region,
1168
- baseUrl: controlBaseUrl
1169
- });
1170
- const s3BaseUrl = opts.s3BaseUrl ?? envVar("ARCHIL_S3_BASE_URL") ?? deriveS3BaseUrl(controlBaseUrl);
1171
- this._client = client;
1172
- this.disks = new Disks(client, region, s3BaseUrl);
1173
- this.tokens = new Tokens(client);
1174
- }
1175
- /**
1176
- * Run a command in a container with multiple disks mounted simultaneously,
1177
- * each at its own relative path under `/mnt/archil`. Blocks until the
1178
- * command completes and returns its stdout, stderr, exit code, and timing.
1179
- */
1180
- async exec(opts) {
1181
- const disks = {};
1182
- for (const [relPath, mount] of Object.entries(opts.disks)) {
1183
- if (isExecMountSpec(mount)) {
1184
- const entry = {
1185
- disk: diskIdFromMount(mount.disk),
1186
- readOnly: mount.readOnly ?? false,
1187
- conditional: mount.conditional ?? false
1188
- };
1189
- if (mount.subdirectory !== void 0) entry.subdirectory = mount.subdirectory;
1190
- disks[relPath] = entry;
1191
- } else {
1192
- disks[relPath] = diskIdFromMount(mount);
1193
- }
1194
- }
1195
- return unwrap(
1196
- this._client.POST("/api/exec", {
1197
- body: { disks, command: opts.command }
1198
- })
1199
- );
1200
- }
1367
+ disks;
1368
+ tokens;
1369
+ /** @internal */
1370
+ _client;
1371
+ constructor(opts = {}) {
1372
+ const apiKey = opts.apiKey ?? envVar("ARCHIL_API_KEY");
1373
+ const region = opts.region ?? envVar("ARCHIL_REGION");
1374
+ if (!apiKey) throw new Error("Missing API key: pass apiKey in options or set ARCHIL_API_KEY environment variable");
1375
+ if (!region) throw new Error("Missing region: pass region in options or set ARCHIL_REGION environment variable");
1376
+ const controlBaseUrl = opts.baseUrl ?? resolveBaseUrl(region);
1377
+ const client = createApiClient({
1378
+ apiKey,
1379
+ region,
1380
+ baseUrl: controlBaseUrl
1381
+ });
1382
+ const s3BaseUrl = opts.s3BaseUrl ?? envVar("ARCHIL_S3_BASE_URL") ?? deriveS3BaseUrl(controlBaseUrl);
1383
+ this._client = client;
1384
+ this.disks = new Disks(client, region, s3BaseUrl);
1385
+ this.tokens = new Tokens(client);
1386
+ }
1387
+ /**
1388
+ * Run a command in a container with multiple disks mounted simultaneously,
1389
+ * each at its own relative path under `/mnt/archil`. Blocks until the
1390
+ * command completes and returns its stdout, stderr, exit code, and timing.
1391
+ */
1392
+ async exec(opts) {
1393
+ const disks = {};
1394
+ for (const [relPath, mount] of Object.entries(opts.disks)) if (isExecMountSpec(mount)) {
1395
+ const entry = {
1396
+ disk: diskIdFromMount(mount.disk),
1397
+ readOnly: mount.readOnly ?? false,
1398
+ conditional: mount.conditional ?? false
1399
+ };
1400
+ if (mount.subdirectory !== void 0) entry.subdirectory = mount.subdirectory;
1401
+ if (mount.queueMs !== void 0) entry.queueMs = mount.queueMs;
1402
+ if (mount.checkoutPaths !== void 0) entry.checkoutPaths = mount.checkoutPaths;
1403
+ disks[relPath] = entry;
1404
+ } else disks[relPath] = diskIdFromMount(mount);
1405
+ return unwrap(this._client.POST("/api/exec", { body: {
1406
+ disks,
1407
+ command: opts.command
1408
+ } }));
1409
+ }
1410
+ /**
1411
+ * Build an agent filesystem toolset spanning several disks at once. `mounts`
1412
+ * maps a relative path to a disk (or `ExecMountSpec`), exactly like
1413
+ * {@link exec}; each disk appears under `/mnt/archil/<path>`. Hand the result
1414
+ * to a framework adapter (`createDiskTools(workspace)`) to get tools that route
1415
+ * file operations by path and fan `grep`/`list_files` out across the disks.
1416
+ */
1417
+ workspace(mounts) {
1418
+ return new Workspace(this, mounts);
1419
+ }
1201
1420
  };
1202
-
1203
- // src/index.ts
1204
- var _options;
1205
- var _instance;
1421
+ //#endregion
1422
+ //#region src/index.ts
1423
+ let _options;
1424
+ let _instance;
1206
1425
  function configure(options) {
1207
- _options = options;
1208
- _instance = void 0;
1426
+ _options = options;
1427
+ _instance = void 0;
1209
1428
  }
1210
1429
  function archil() {
1211
- if (!_instance) {
1212
- _instance = new Archil(_options);
1213
- }
1214
- return _instance;
1430
+ if (!_instance) _instance = new Archil(_options);
1431
+ return _instance;
1215
1432
  }
1216
1433
  function createDisk(req) {
1217
- return archil().disks.create(req);
1434
+ return archil().disks.create(req);
1218
1435
  }
1219
1436
  function listDisks(opts) {
1220
- return archil().disks.list(opts);
1437
+ return archil().disks.list(opts);
1221
1438
  }
1222
1439
  function getDisk(id) {
1223
- return archil().disks.get(id);
1440
+ return archil().disks.get(id);
1224
1441
  }
1225
1442
  function listApiKeys(opts) {
1226
- return archil().tokens.list(opts);
1443
+ return archil().tokens.list(opts);
1227
1444
  }
1228
1445
  function createApiKey(req) {
1229
- return archil().tokens.create(req);
1446
+ return archil().tokens.create(req);
1230
1447
  }
1231
1448
  function deleteApiKey(id) {
1232
- return archil().tokens.delete(id);
1449
+ return archil().tokens.delete(id);
1233
1450
  }
1451
+ /**
1452
+ * Run a command in a container with multiple disks mounted simultaneously,
1453
+ * each at its own relative path under `/mnt/archil`. Blocks until the
1454
+ * command completes and returns its stdout, stderr, exit code, and timing.
1455
+ */
1234
1456
  function exec(opts) {
1235
- return archil().exec(opts);
1457
+ return archil().exec(opts);
1236
1458
  }
1237
- // Annotate the CommonJS export names for ESM import in node:
1238
- 0 && (module.exports = {
1239
- Archil,
1240
- ArchilApiError,
1241
- ArchilError,
1242
- ArchilS3Error,
1243
- Disk,
1244
- DiskMultipart,
1245
- Disks,
1246
- Tokens,
1247
- USER_AGENT,
1248
- VERSION,
1249
- configure,
1250
- createApiKey,
1251
- createDisk,
1252
- deleteApiKey,
1253
- effectiveUploadPartSize,
1254
- exec,
1255
- getDisk,
1256
- listApiKeys,
1257
- listDisks
1258
- });
1459
+ /**
1460
+ * Build an agent filesystem toolset spanning several disks, using the
1461
+ * module-level client. See {@link Archil.workspace}.
1462
+ */
1463
+ function workspace(mounts) {
1464
+ return archil().workspace(mounts);
1465
+ }
1466
+ //#endregion
1467
+ exports.Archil = Archil;
1468
+ exports.ArchilApiError = ArchilApiError;
1469
+ exports.ArchilError = ArchilError;
1470
+ exports.ArchilS3Error = ArchilS3Error;
1471
+ exports.Disk = Disk;
1472
+ exports.DiskMultipart = DiskMultipart;
1473
+ exports.Disks = Disks;
1474
+ exports.Tokens = Tokens;
1475
+ exports.USER_AGENT = USER_AGENT;
1476
+ exports.VERSION = VERSION;
1477
+ exports.Workspace = Workspace;
1478
+ exports.configure = configure;
1479
+ exports.createApiKey = createApiKey;
1480
+ exports.createDisk = createDisk;
1481
+ exports.deleteApiKey = deleteApiKey;
1482
+ exports.effectiveUploadPartSize = effectiveUploadPartSize;
1483
+ exports.exec = exec;
1484
+ exports.getDisk = getDisk;
1485
+ exports.listApiKeys = listApiKeys;
1486
+ exports.listDisks = listDisks;
1487
+ exports.workspace = workspace;