disk 0.8.11 → 0.8.13

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
@@ -32,9 +32,13 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  Archil: () => Archil,
34
34
  ArchilApiError: () => ArchilApiError,
35
+ ArchilError: () => ArchilError,
36
+ ArchilS3Error: () => ArchilS3Error,
35
37
  Disk: () => Disk,
36
38
  Disks: () => Disks,
37
39
  Tokens: () => Tokens,
40
+ USER_AGENT: () => USER_AGENT,
41
+ VERSION: () => VERSION,
38
42
  configure: () => configure,
39
43
  createApiKey: () => createApiKey,
40
44
  createDisk: () => createDisk,
@@ -49,17 +53,77 @@ module.exports = __toCommonJS(index_exports);
49
53
  // src/client.ts
50
54
  var import_openapi_fetch = __toESM(require("openapi-fetch"), 1);
51
55
 
56
+ // src/s3xml.ts
57
+ var import_fast_xml_parser = require("fast-xml-parser");
58
+ var parser = new import_fast_xml_parser.XMLParser({
59
+ isArray: (name) => name === "Contents" || name === "CommonPrefixes",
60
+ parseTagValue: false,
61
+ trimValues: true,
62
+ ignoreAttributes: true
63
+ });
64
+ function parseXml(xml) {
65
+ if (!xml.trim()) return {};
66
+ return parser.parse(xml);
67
+ }
68
+
52
69
  // src/errors.ts
53
- var ArchilApiError = class extends Error {
70
+ var ArchilError = class extends Error {
71
+ /** HTTP status code associated with the failure. */
54
72
  status;
73
+ /** Machine-readable error code (e.g. an S3 code like "NoSuchKey"), if known. */
55
74
  code;
56
75
  constructor(message, status, code) {
57
76
  super(message);
58
- this.name = "ArchilApiError";
77
+ this.name = "ArchilError";
59
78
  this.status = status;
60
79
  this.code = code;
61
80
  }
62
81
  };
82
+ var ArchilApiError = class extends ArchilError {
83
+ constructor(message, status, code) {
84
+ super(message, status, code);
85
+ this.name = "ArchilApiError";
86
+ }
87
+ };
88
+ var ArchilS3Error = class extends ArchilError {
89
+ /** S3 request id, if the gateway returned one. */
90
+ requestId;
91
+ /** Raw response body (the XML document), for debugging. */
92
+ raw;
93
+ constructor(opts) {
94
+ const detail = opts.message ?? opts.statusText ?? "";
95
+ const codePart = opts.code ? ` ${opts.code}` : "";
96
+ super(
97
+ `S3 ${opts.operation} failed: ${opts.statusCode}${codePart}${detail ? ` \u2014 ${detail}` : ""}`,
98
+ opts.statusCode,
99
+ opts.code
100
+ );
101
+ this.name = "ArchilS3Error";
102
+ this.requestId = opts.requestId;
103
+ this.raw = opts.raw;
104
+ }
105
+ };
106
+ function tagString(obj, tag) {
107
+ const value = obj[tag];
108
+ return value === void 0 || value === null ? void 0 : String(value);
109
+ }
110
+ function parseS3Error(operation, statusCode, statusText, body) {
111
+ let err = {};
112
+ try {
113
+ err = parseXml(body).Error ?? {};
114
+ } catch {
115
+ err = {};
116
+ }
117
+ return new ArchilS3Error({
118
+ operation,
119
+ statusCode,
120
+ statusText,
121
+ code: tagString(err, "Code"),
122
+ message: tagString(err, "Message"),
123
+ requestId: tagString(err, "RequestId"),
124
+ raw: body
125
+ });
126
+ }
63
127
 
64
128
  // src/regions.ts
65
129
  var REGION_URLS = {
@@ -77,14 +141,32 @@ function resolveBaseUrl(region) {
77
141
  }
78
142
  return url;
79
143
  }
144
+ function deriveS3BaseUrl(controlBaseUrl) {
145
+ try {
146
+ const u = new URL(controlBaseUrl);
147
+ u.hostname = u.hostname.replace(/^control\./, "s3.");
148
+ return u.toString().replace(/\/$/, "");
149
+ } catch {
150
+ return void 0;
151
+ }
152
+ }
153
+
154
+ // src/version.ts
155
+ var VERSION = true ? "0.8.13" : "0.0.0-dev";
156
+ var USER_AGENT = `archil-js/${VERSION}`;
80
157
 
81
158
  // src/client.ts
159
+ var createClient = typeof import_openapi_fetch.default === "function" ? import_openapi_fetch.default : import_openapi_fetch.default.default;
82
160
  function createApiClient(opts) {
83
161
  const baseUrl = opts.baseUrl ?? resolveBaseUrl(opts.region);
84
- return (0, import_openapi_fetch.default)({
162
+ return createClient({
85
163
  baseUrl,
86
164
  headers: {
87
- Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`
165
+ Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`,
166
+ // Identifies the JS SDK (and its version) to the control plane. Honored
167
+ // by Node's fetch; browsers treat User-Agent as a forbidden header and
168
+ // drop it, which is fine — the SDK's primary use is server-side.
169
+ "User-Agent": USER_AGENT
88
170
  }
89
171
  });
90
172
  }
@@ -123,9 +205,6 @@ async function unwrapEmpty(promise) {
123
205
  }
124
206
 
125
207
  // src/disk.ts
126
- var import_node_module = require("module");
127
- var import_node_url = require("url");
128
- var import_meta = {};
129
208
  var Disk = class {
130
209
  id;
131
210
  name;
@@ -147,8 +226,10 @@ var Disk = class {
147
226
  _client;
148
227
  /** @internal */
149
228
  _archilRegion;
229
+ /** Base URL for the S3-compatible API (port 9000 ingress). Empty if unset. */
230
+ _s3BaseUrl;
150
231
  /** @internal */
151
- constructor(data, client, archilRegion) {
232
+ constructor(data, client, archilRegion, s3BaseUrl) {
152
233
  this.id = data.id;
153
234
  this.name = data.name;
154
235
  this.organization = data.organization;
@@ -167,6 +248,7 @@ var Disk = class {
167
248
  this.allowedIps = data.allowedIps;
168
249
  this._client = client;
169
250
  this._archilRegion = archilRegion;
251
+ this._s3BaseUrl = s3BaseUrl ?? "";
170
252
  }
171
253
  toJSON() {
172
254
  return {
@@ -266,6 +348,244 @@ var Disk = class {
266
348
  })
267
349
  );
268
350
  }
351
+ /**
352
+ * Constant-time parallel grep across files on this disk. Listing and
353
+ * matching are fanned out across ephemeral exec containers; the request
354
+ * finishes within the supplied time budget regardless of dataset size.
355
+ *
356
+ * The returned `stoppedReason` says whether the search ran to completion
357
+ * or short-circuited on `maxResults` / `maxDurationSeconds`. When
358
+ * stopping early, the matches returned are a sample (whichever workers
359
+ * reported first), not the lexicographically first N.
360
+ */
361
+ async grep(opts) {
362
+ return unwrap(
363
+ this._client.POST("/api/disks/{id}/grep", {
364
+ params: { path: { id: this.id } },
365
+ body: {
366
+ directory: opts.directory,
367
+ pattern: opts.pattern,
368
+ recursive: opts.recursive ?? false,
369
+ maxDurationSeconds: opts.maxDurationSeconds ?? 30,
370
+ concurrency: opts.concurrency ?? 50,
371
+ maxResults: opts.maxResults ?? 1e3
372
+ }
373
+ })
374
+ );
375
+ }
376
+ /**
377
+ * Create a signed, time-limited URL that lets anyone download a single file
378
+ * from this disk without authentication. The returned URL embeds a
379
+ * cryptographically signed token carrying the disk, the file's key, and an
380
+ * expiry — share it directly; no API key is needed to redeem it.
381
+ *
382
+ * @param key Path to the file on the disk (e.g. "reports/2026-01/data.pdf").
383
+ * @param opts `expiresIn` sets the URL lifetime in seconds (any positive
384
+ * integer, max 604800 = 7 days). Defaults to 24h.
385
+ */
386
+ async share(key, opts = {}) {
387
+ const body = { key };
388
+ if (opts.expiresIn !== void 0) body.expiresIn = opts.expiresIn;
389
+ const call = this._client.POST;
390
+ return unwrap(call(`/api/disks/${this.id}/share`, { body }));
391
+ }
392
+ /**
393
+ * Read an object from the disk via the S3-compatible GetObject API and return
394
+ * its full contents as bytes. Throws `ArchilS3Error` if the object does not
395
+ * exist (status 404, code "NoSuchKey") or the request is rejected; use
396
+ * `headObject`/`objectExists` to check existence without throwing.
397
+ *
398
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
399
+ */
400
+ async getObject(key) {
401
+ const resp = await this._s3Request("GET", key);
402
+ if (!resp.ok) {
403
+ throw parseS3Error("GetObject", resp.status, resp.statusText, decodeText(resp.body));
404
+ }
405
+ return resp.body;
406
+ }
407
+ /**
408
+ * Fetch an object's metadata (size, etag, content type, last-modified) without
409
+ * downloading its contents, via the S3-compatible HeadObject API. Returns
410
+ * `null` if the object does not exist.
411
+ *
412
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
413
+ */
414
+ async headObject(key) {
415
+ const resp = await this._s3Request("HEAD", key);
416
+ if (resp.status === 404) return null;
417
+ if (!resp.ok) {
418
+ throw parseS3Error("HeadObject", resp.status, resp.statusText, decodeText(resp.body));
419
+ }
420
+ const lastModified = resp.headers.get("last-modified");
421
+ return {
422
+ size: Number(resp.headers.get("content-length") ?? 0),
423
+ etag: resp.headers.get("etag") ?? void 0,
424
+ contentType: resp.headers.get("content-type") ?? void 0,
425
+ lastModified: lastModified ? new Date(lastModified) : void 0
426
+ };
427
+ }
428
+ /** Whether an object exists on the disk (a HeadObject that maps 404 → false). */
429
+ async objectExists(key) {
430
+ return await this.headObject(key) !== null;
431
+ }
432
+ /**
433
+ * Write an object to the disk using the S3-compatible PutObject API. Faster
434
+ * than exec for large files — no container overhead, no command-length limits.
435
+ * Returns the entity tag the server assigned.
436
+ *
437
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
438
+ * @param body Contents as a string, Uint8Array/Buffer, or ArrayBuffer
439
+ * @param contentType MIME type (default: "application/octet-stream")
440
+ */
441
+ async putObject(key, body, contentType = "application/octet-stream") {
442
+ const resp = await this._s3Request("PUT", key, { body, contentType });
443
+ if (!resp.ok) {
444
+ throw parseS3Error("PutObject", resp.status, resp.statusText, decodeText(resp.body));
445
+ }
446
+ return { etag: resp.headers.get("etag") ?? void 0 };
447
+ }
448
+ /**
449
+ * Delete an object from the disk via the S3-compatible DeleteObject API.
450
+ * Idempotent: deleting a key that doesn't exist resolves successfully, per
451
+ * S3 semantics.
452
+ *
453
+ * @param key Path on the disk (e.g. "project/dist/server.cjs")
454
+ */
455
+ async deleteObject(key) {
456
+ const resp = await this._s3Request("DELETE", key);
457
+ if (!resp.ok && resp.status !== 404) {
458
+ throw parseS3Error("DeleteObject", resp.status, resp.statusText, decodeText(resp.body));
459
+ }
460
+ }
461
+ /**
462
+ * List objects on the disk via the S3-compatible ListObjectsV2 API. By
463
+ * default this follows continuation tokens until the listing is exhausted and
464
+ * returns every matching key. Use `limit` to cap the total, `singlePage` for a
465
+ * single request, or {@link listObjectsPages} to stream pages without loading
466
+ * everything into memory.
467
+ *
468
+ * @param prefix Only return keys beginning with this prefix (omit for all).
469
+ * @param opts Listing and pagination options.
470
+ */
471
+ async listObjects(prefix, opts = {}) {
472
+ if (opts.singlePage) {
473
+ return this._listObjectsPage(prefix, opts);
474
+ }
475
+ const objects = [];
476
+ const commonPrefixes = [];
477
+ const seenPrefixes = /* @__PURE__ */ new Set();
478
+ let echoedPrefix;
479
+ let truncated = false;
480
+ outer: for await (const page of this.listObjectsPages(prefix, opts)) {
481
+ echoedPrefix = page.prefix;
482
+ for (const cp of page.commonPrefixes) {
483
+ if (!seenPrefixes.has(cp)) {
484
+ seenPrefixes.add(cp);
485
+ commonPrefixes.push(cp);
486
+ }
487
+ }
488
+ for (const obj of page.objects) {
489
+ if (opts.limit !== void 0 && objects.length >= opts.limit) {
490
+ truncated = true;
491
+ break outer;
492
+ }
493
+ objects.push(obj);
494
+ }
495
+ }
496
+ return { objects, commonPrefixes, isTruncated: truncated, keyCount: objects.length, prefix: echoedPrefix };
497
+ }
498
+ /**
499
+ * Yield ListObjectsV2 pages lazily, following continuation tokens. A
500
+ * memory-friendly way to process a large listing without materializing it:
501
+ *
502
+ * ```ts
503
+ * for await (const page of disk.listObjectsPages("logs/")) {
504
+ * for (const obj of page.objects) process(obj);
505
+ * }
506
+ * ```
507
+ *
508
+ * @param prefix Only return keys beginning with this prefix (omit for all).
509
+ * @param opts Listing options (`limit` / `singlePage` are ignored here —
510
+ * control your own loop).
511
+ */
512
+ async *listObjectsPages(prefix, opts = {}) {
513
+ const seenTokens = /* @__PURE__ */ new Set();
514
+ let continuationToken = opts.continuationToken;
515
+ for (; ; ) {
516
+ const page = await this._listObjectsPage(prefix, { ...opts, continuationToken });
517
+ yield page;
518
+ const next = page.isTruncated ? page.nextContinuationToken : void 0;
519
+ if (!next || seenTokens.has(next)) break;
520
+ seenTokens.add(next);
521
+ continuationToken = next;
522
+ }
523
+ }
524
+ /** Fetch a single ListObjectsV2 page. @internal */
525
+ async _listObjectsPage(prefix, opts) {
526
+ const query = { "list-type": 2 };
527
+ if (prefix !== void 0) query.prefix = prefix;
528
+ if (!opts.recursive) query.delimiter = "/";
529
+ if (opts.continuationToken !== void 0) query["continuation-token"] = opts.continuationToken;
530
+ if (opts.startAfter !== void 0) query["start-after"] = opts.startAfter;
531
+ const resp = await this._s3Request("GET", "", { query });
532
+ if (!resp.ok) {
533
+ throw parseS3Error("ListObjectsV2", resp.status, resp.statusText, decodeText(resp.body));
534
+ }
535
+ return parseListObjectsResult(decodeText(resp.body));
536
+ }
537
+ /**
538
+ * Send a single request to the disk's S3-compatible endpoint. This reuses the
539
+ * control-plane client purely for its credential and transport — the same
540
+ * `Authorization` header is sent and verified by the same code server-side —
541
+ * pointed at the S3 host. Returns the response status and fully-buffered body
542
+ * so callers can inspect both regardless of the verb used.
543
+ *
544
+ * The S3 routes (`/{diskId}/{key}` for objects, `/{diskId}` for the bucket)
545
+ * are not part of the typed control-plane API, so the path and per-request
546
+ * options are passed untyped. An empty `key` targets the bucket itself (used
547
+ * by listObjects).
548
+ *
549
+ * @internal
550
+ */
551
+ async _s3Request(method, key, opts = {}) {
552
+ if (!this._s3BaseUrl) {
553
+ throw new Error(
554
+ "S3 base URL not configured. Pass s3BaseUrl to new Archil({...}) or set ARCHIL_S3_BASE_URL."
555
+ );
556
+ }
557
+ const call = this._client[method];
558
+ const trimmedKey = key.replace(/^\//, "");
559
+ const encodedKey = trimmedKey.split("/").map(encodeURIComponent).join("/");
560
+ const path = encodedKey ? `/${this.id}/${encodedKey}` : `/${this.id}`;
561
+ const { error, response } = await call(path, {
562
+ baseUrl: this._s3BaseUrl,
563
+ parseAs: "stream",
564
+ ...opts.query ? { params: { query: opts.query } } : {},
565
+ // fetch accepts string / Uint8Array / ArrayBuffer bodies directly; pass it
566
+ // through unchanged (no Node Buffer in the data path).
567
+ ...opts.body !== void 0 ? { body: opts.body, bodySerializer: (b) => b } : {},
568
+ ...opts.contentType ? { headers: { "Content-Type": opts.contentType } } : {}
569
+ });
570
+ if (!response.ok) {
571
+ const message = typeof error === "string" ? error : error ? JSON.stringify(error) : "";
572
+ return {
573
+ ok: false,
574
+ status: response.status,
575
+ statusText: response.statusText,
576
+ headers: response.headers,
577
+ body: new TextEncoder().encode(message)
578
+ };
579
+ }
580
+ const bytes = method === "GET" ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array(0);
581
+ return {
582
+ ok: true,
583
+ status: response.status,
584
+ statusText: response.statusText,
585
+ headers: response.headers,
586
+ body: bytes
587
+ };
588
+ }
269
589
  /**
270
590
  * Connect to this disk's data plane via the native ArchilClient.
271
591
  *
@@ -274,13 +594,15 @@ var Disk = class {
274
594
  async mount(opts) {
275
595
  let ArchilClient;
276
596
  try {
277
- const base = typeof import_meta !== "undefined" && import_meta.url ? import_meta.url : (0, import_node_url.pathToFileURL)(__filename).href;
278
- const nativeRequire = (0, import_node_module.createRequire)(base);
279
- const native = nativeRequire("@archildata/native");
280
- ArchilClient = native.ArchilClient;
597
+ const nativeSpecifier = "@archildata/native";
598
+ const native = await import(nativeSpecifier);
599
+ ArchilClient = native.ArchilClient ?? native.default?.ArchilClient;
600
+ if (!ArchilClient) {
601
+ throw new Error("@archildata/native did not export ArchilClient");
602
+ }
281
603
  } catch {
282
604
  throw new Error(
283
- "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly."
605
+ "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly. mount() is not supported in browsers."
284
606
  );
285
607
  }
286
608
  return ArchilClient.connect({
@@ -293,6 +615,34 @@ var Disk = class {
293
615
  });
294
616
  }
295
617
  };
618
+ function optionalString(value) {
619
+ return value === void 0 || value === null ? void 0 : String(value);
620
+ }
621
+ function optionalDate(value) {
622
+ return value === void 0 || value === null ? void 0 : new Date(String(value));
623
+ }
624
+ function decodeText(bytes) {
625
+ return new TextDecoder().decode(bytes);
626
+ }
627
+ function parseListObjectsResult(xml) {
628
+ const root = parseXml(xml).ListBucketResult ?? {};
629
+ const contents = root.Contents ?? [];
630
+ const objects = contents.map((c) => ({
631
+ key: String(c.Key ?? ""),
632
+ size: Number(c.Size ?? 0),
633
+ etag: optionalString(c.ETag),
634
+ lastModified: optionalDate(c.LastModified)
635
+ }));
636
+ const commonPrefixes = (root.CommonPrefixes ?? []).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
637
+ return {
638
+ objects,
639
+ commonPrefixes,
640
+ isTruncated: root.IsTruncated === "true" || root.IsTruncated === true,
641
+ nextContinuationToken: optionalString(root.NextContinuationToken),
642
+ keyCount: root.KeyCount !== void 0 ? Number(root.KeyCount) : objects.length,
643
+ prefix: optionalString(root.Prefix)
644
+ };
645
+ }
296
646
 
297
647
  // src/disks.ts
298
648
  var Disks = class {
@@ -301,9 +651,12 @@ var Disks = class {
301
651
  /** @internal */
302
652
  _region;
303
653
  /** @internal */
304
- constructor(client, region) {
654
+ _s3BaseUrl;
655
+ /** @internal */
656
+ constructor(client, region, s3BaseUrl) {
305
657
  this._client = client;
306
658
  this._region = region;
659
+ this._s3BaseUrl = s3BaseUrl;
307
660
  }
308
661
  async list(opts) {
309
662
  const data = await unwrap(
@@ -312,7 +665,7 @@ var Disks = class {
312
665
  })
313
666
  );
314
667
  return data.map(
315
- (d) => new Disk(d, this._client, this._region)
668
+ (d) => new Disk(d, this._client, this._region, this._s3BaseUrl)
316
669
  );
317
670
  }
318
671
  async get(id) {
@@ -321,7 +674,7 @@ var Disks = class {
321
674
  params: { path: { id } }
322
675
  })
323
676
  );
324
- return new Disk(data, this._client, this._region);
677
+ return new Disk(data, this._client, this._region, this._s3BaseUrl);
325
678
  }
326
679
  /**
327
680
  * Create a new disk with an auto-generated mount token.
@@ -381,6 +734,9 @@ var Tokens = class {
381
734
  };
382
735
 
383
736
  // src/archil.ts
737
+ function envVar(name) {
738
+ return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
739
+ }
384
740
  function isExecMountSpec(m) {
385
741
  return typeof m === "object" && m !== null && "disk" in m;
386
742
  }
@@ -393,21 +749,23 @@ var Archil = class {
393
749
  /** @internal */
394
750
  _client;
395
751
  constructor(opts = {}) {
396
- const apiKey = opts.apiKey ?? process.env.ARCHIL_API_KEY;
397
- const region = opts.region ?? process.env.ARCHIL_REGION;
752
+ const apiKey = opts.apiKey ?? envVar("ARCHIL_API_KEY");
753
+ const region = opts.region ?? envVar("ARCHIL_REGION");
398
754
  if (!apiKey) {
399
755
  throw new Error("Missing API key: pass apiKey in options or set ARCHIL_API_KEY environment variable");
400
756
  }
401
757
  if (!region) {
402
758
  throw new Error("Missing region: pass region in options or set ARCHIL_REGION environment variable");
403
759
  }
760
+ const controlBaseUrl = opts.baseUrl ?? resolveBaseUrl(region);
404
761
  const client = createApiClient({
405
762
  apiKey,
406
763
  region,
407
- baseUrl: opts.baseUrl
764
+ baseUrl: controlBaseUrl
408
765
  });
766
+ const s3BaseUrl = opts.s3BaseUrl ?? envVar("ARCHIL_S3_BASE_URL") ?? deriveS3BaseUrl(controlBaseUrl);
409
767
  this._client = client;
410
- this.disks = new Disks(client, region);
768
+ this.disks = new Disks(client, region, s3BaseUrl);
411
769
  this.tokens = new Tokens(client);
412
770
  }
413
771
  /**
@@ -475,9 +833,13 @@ function exec(opts) {
475
833
  0 && (module.exports = {
476
834
  Archil,
477
835
  ArchilApiError,
836
+ ArchilError,
837
+ ArchilS3Error,
478
838
  Disk,
479
839
  Disks,
480
840
  Tokens,
841
+ USER_AGENT,
842
+ VERSION,
481
843
  configure,
482
844
  createApiKey,
483
845
  createDisk,