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.js CHANGED
@@ -1,17 +1,77 @@
1
1
  // src/client.ts
2
- import createClient from "openapi-fetch";
2
+ import createClientDefault from "openapi-fetch";
3
+
4
+ // src/s3xml.ts
5
+ import { XMLParser } from "fast-xml-parser";
6
+ var parser = new XMLParser({
7
+ isArray: (name) => name === "Contents" || name === "CommonPrefixes",
8
+ parseTagValue: false,
9
+ trimValues: true,
10
+ ignoreAttributes: true
11
+ });
12
+ function parseXml(xml) {
13
+ if (!xml.trim()) return {};
14
+ return parser.parse(xml);
15
+ }
3
16
 
4
17
  // src/errors.ts
5
- var ArchilApiError = class extends Error {
18
+ var ArchilError = class extends Error {
19
+ /** HTTP status code associated with the failure. */
6
20
  status;
21
+ /** Machine-readable error code (e.g. an S3 code like "NoSuchKey"), if known. */
7
22
  code;
8
23
  constructor(message, status, code) {
9
24
  super(message);
10
- this.name = "ArchilApiError";
25
+ this.name = "ArchilError";
11
26
  this.status = status;
12
27
  this.code = code;
13
28
  }
14
29
  };
30
+ var ArchilApiError = class extends ArchilError {
31
+ constructor(message, status, code) {
32
+ super(message, status, code);
33
+ this.name = "ArchilApiError";
34
+ }
35
+ };
36
+ var ArchilS3Error = class extends ArchilError {
37
+ /** S3 request id, if the gateway returned one. */
38
+ requestId;
39
+ /** Raw response body (the XML document), for debugging. */
40
+ raw;
41
+ constructor(opts) {
42
+ const detail = opts.message ?? opts.statusText ?? "";
43
+ const codePart = opts.code ? ` ${opts.code}` : "";
44
+ super(
45
+ `S3 ${opts.operation} failed: ${opts.statusCode}${codePart}${detail ? ` \u2014 ${detail}` : ""}`,
46
+ opts.statusCode,
47
+ opts.code
48
+ );
49
+ this.name = "ArchilS3Error";
50
+ this.requestId = opts.requestId;
51
+ this.raw = opts.raw;
52
+ }
53
+ };
54
+ function tagString(obj, tag) {
55
+ const value = obj[tag];
56
+ return value === void 0 || value === null ? void 0 : String(value);
57
+ }
58
+ function parseS3Error(operation, statusCode, statusText, body) {
59
+ let err = {};
60
+ try {
61
+ err = parseXml(body).Error ?? {};
62
+ } catch {
63
+ err = {};
64
+ }
65
+ return new ArchilS3Error({
66
+ operation,
67
+ statusCode,
68
+ statusText,
69
+ code: tagString(err, "Code"),
70
+ message: tagString(err, "Message"),
71
+ requestId: tagString(err, "RequestId"),
72
+ raw: body
73
+ });
74
+ }
15
75
 
16
76
  // src/regions.ts
17
77
  var REGION_URLS = {
@@ -29,14 +89,32 @@ function resolveBaseUrl(region) {
29
89
  }
30
90
  return url;
31
91
  }
92
+ function deriveS3BaseUrl(controlBaseUrl) {
93
+ try {
94
+ const u = new URL(controlBaseUrl);
95
+ u.hostname = u.hostname.replace(/^control\./, "s3.");
96
+ return u.toString().replace(/\/$/, "");
97
+ } catch {
98
+ return void 0;
99
+ }
100
+ }
101
+
102
+ // src/version.ts
103
+ var VERSION = true ? "0.8.13" : "0.0.0-dev";
104
+ var USER_AGENT = `archil-js/${VERSION}`;
32
105
 
33
106
  // src/client.ts
107
+ var createClient = typeof createClientDefault === "function" ? createClientDefault : createClientDefault.default;
34
108
  function createApiClient(opts) {
35
109
  const baseUrl = opts.baseUrl ?? resolveBaseUrl(opts.region);
36
110
  return createClient({
37
111
  baseUrl,
38
112
  headers: {
39
- Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`
113
+ Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`,
114
+ // Identifies the JS SDK (and its version) to the control plane. Honored
115
+ // by Node's fetch; browsers treat User-Agent as a forbidden header and
116
+ // drop it, which is fine — the SDK's primary use is server-side.
117
+ "User-Agent": USER_AGENT
40
118
  }
41
119
  });
42
120
  }
@@ -75,8 +153,6 @@ async function unwrapEmpty(promise) {
75
153
  }
76
154
 
77
155
  // src/disk.ts
78
- import { createRequire } from "module";
79
- import { pathToFileURL } from "url";
80
156
  var Disk = class {
81
157
  id;
82
158
  name;
@@ -98,8 +174,10 @@ var Disk = class {
98
174
  _client;
99
175
  /** @internal */
100
176
  _archilRegion;
177
+ /** Base URL for the S3-compatible API (port 9000 ingress). Empty if unset. */
178
+ _s3BaseUrl;
101
179
  /** @internal */
102
- constructor(data, client, archilRegion) {
180
+ constructor(data, client, archilRegion, s3BaseUrl) {
103
181
  this.id = data.id;
104
182
  this.name = data.name;
105
183
  this.organization = data.organization;
@@ -118,6 +196,7 @@ var Disk = class {
118
196
  this.allowedIps = data.allowedIps;
119
197
  this._client = client;
120
198
  this._archilRegion = archilRegion;
199
+ this._s3BaseUrl = s3BaseUrl ?? "";
121
200
  }
122
201
  toJSON() {
123
202
  return {
@@ -217,6 +296,244 @@ var Disk = class {
217
296
  })
218
297
  );
219
298
  }
299
+ /**
300
+ * Constant-time parallel grep across files on this disk. Listing and
301
+ * matching are fanned out across ephemeral exec containers; the request
302
+ * finishes within the supplied time budget regardless of dataset size.
303
+ *
304
+ * The returned `stoppedReason` says whether the search ran to completion
305
+ * or short-circuited on `maxResults` / `maxDurationSeconds`. When
306
+ * stopping early, the matches returned are a sample (whichever workers
307
+ * reported first), not the lexicographically first N.
308
+ */
309
+ async grep(opts) {
310
+ return unwrap(
311
+ this._client.POST("/api/disks/{id}/grep", {
312
+ params: { path: { id: this.id } },
313
+ body: {
314
+ directory: opts.directory,
315
+ pattern: opts.pattern,
316
+ recursive: opts.recursive ?? false,
317
+ maxDurationSeconds: opts.maxDurationSeconds ?? 30,
318
+ concurrency: opts.concurrency ?? 50,
319
+ maxResults: opts.maxResults ?? 1e3
320
+ }
321
+ })
322
+ );
323
+ }
324
+ /**
325
+ * Create a signed, time-limited URL that lets anyone download a single file
326
+ * from this disk without authentication. The returned URL embeds a
327
+ * cryptographically signed token carrying the disk, the file's key, and an
328
+ * expiry — share it directly; no API key is needed to redeem it.
329
+ *
330
+ * @param key Path to the file on the disk (e.g. "reports/2026-01/data.pdf").
331
+ * @param opts `expiresIn` sets the URL lifetime in seconds (any positive
332
+ * integer, max 604800 = 7 days). Defaults to 24h.
333
+ */
334
+ async share(key, opts = {}) {
335
+ const body = { key };
336
+ if (opts.expiresIn !== void 0) body.expiresIn = opts.expiresIn;
337
+ const call = this._client.POST;
338
+ return unwrap(call(`/api/disks/${this.id}/share`, { body }));
339
+ }
340
+ /**
341
+ * Read an object from the disk via the S3-compatible GetObject API and return
342
+ * its full contents as bytes. Throws `ArchilS3Error` if the object does not
343
+ * exist (status 404, code "NoSuchKey") or the request is rejected; use
344
+ * `headObject`/`objectExists` to check existence without throwing.
345
+ *
346
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
347
+ */
348
+ async getObject(key) {
349
+ const resp = await this._s3Request("GET", key);
350
+ if (!resp.ok) {
351
+ throw parseS3Error("GetObject", resp.status, resp.statusText, decodeText(resp.body));
352
+ }
353
+ return resp.body;
354
+ }
355
+ /**
356
+ * Fetch an object's metadata (size, etag, content type, last-modified) without
357
+ * downloading its contents, via the S3-compatible HeadObject API. Returns
358
+ * `null` if the object does not exist.
359
+ *
360
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
361
+ */
362
+ async headObject(key) {
363
+ const resp = await this._s3Request("HEAD", key);
364
+ if (resp.status === 404) return null;
365
+ if (!resp.ok) {
366
+ throw parseS3Error("HeadObject", resp.status, resp.statusText, decodeText(resp.body));
367
+ }
368
+ const lastModified = resp.headers.get("last-modified");
369
+ return {
370
+ size: Number(resp.headers.get("content-length") ?? 0),
371
+ etag: resp.headers.get("etag") ?? void 0,
372
+ contentType: resp.headers.get("content-type") ?? void 0,
373
+ lastModified: lastModified ? new Date(lastModified) : void 0
374
+ };
375
+ }
376
+ /** Whether an object exists on the disk (a HeadObject that maps 404 → false). */
377
+ async objectExists(key) {
378
+ return await this.headObject(key) !== null;
379
+ }
380
+ /**
381
+ * Write an object to the disk using the S3-compatible PutObject API. Faster
382
+ * than exec for large files — no container overhead, no command-length limits.
383
+ * Returns the entity tag the server assigned.
384
+ *
385
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
386
+ * @param body Contents as a string, Uint8Array/Buffer, or ArrayBuffer
387
+ * @param contentType MIME type (default: "application/octet-stream")
388
+ */
389
+ async putObject(key, body, contentType = "application/octet-stream") {
390
+ const resp = await this._s3Request("PUT", key, { body, contentType });
391
+ if (!resp.ok) {
392
+ throw parseS3Error("PutObject", resp.status, resp.statusText, decodeText(resp.body));
393
+ }
394
+ return { etag: resp.headers.get("etag") ?? void 0 };
395
+ }
396
+ /**
397
+ * Delete an object from the disk via the S3-compatible DeleteObject API.
398
+ * Idempotent: deleting a key that doesn't exist resolves successfully, per
399
+ * S3 semantics.
400
+ *
401
+ * @param key Path on the disk (e.g. "project/dist/server.cjs")
402
+ */
403
+ async deleteObject(key) {
404
+ const resp = await this._s3Request("DELETE", key);
405
+ if (!resp.ok && resp.status !== 404) {
406
+ throw parseS3Error("DeleteObject", resp.status, resp.statusText, decodeText(resp.body));
407
+ }
408
+ }
409
+ /**
410
+ * List objects on the disk via the S3-compatible ListObjectsV2 API. By
411
+ * default this follows continuation tokens until the listing is exhausted and
412
+ * returns every matching key. Use `limit` to cap the total, `singlePage` for a
413
+ * single request, or {@link listObjectsPages} to stream pages without loading
414
+ * everything into memory.
415
+ *
416
+ * @param prefix Only return keys beginning with this prefix (omit for all).
417
+ * @param opts Listing and pagination options.
418
+ */
419
+ async listObjects(prefix, opts = {}) {
420
+ if (opts.singlePage) {
421
+ return this._listObjectsPage(prefix, opts);
422
+ }
423
+ const objects = [];
424
+ const commonPrefixes = [];
425
+ const seenPrefixes = /* @__PURE__ */ new Set();
426
+ let echoedPrefix;
427
+ let truncated = false;
428
+ outer: for await (const page of this.listObjectsPages(prefix, opts)) {
429
+ echoedPrefix = page.prefix;
430
+ for (const cp of page.commonPrefixes) {
431
+ if (!seenPrefixes.has(cp)) {
432
+ seenPrefixes.add(cp);
433
+ commonPrefixes.push(cp);
434
+ }
435
+ }
436
+ for (const obj of page.objects) {
437
+ if (opts.limit !== void 0 && objects.length >= opts.limit) {
438
+ truncated = true;
439
+ break outer;
440
+ }
441
+ objects.push(obj);
442
+ }
443
+ }
444
+ return { objects, commonPrefixes, isTruncated: truncated, keyCount: objects.length, prefix: echoedPrefix };
445
+ }
446
+ /**
447
+ * Yield ListObjectsV2 pages lazily, following continuation tokens. A
448
+ * memory-friendly way to process a large listing without materializing it:
449
+ *
450
+ * ```ts
451
+ * for await (const page of disk.listObjectsPages("logs/")) {
452
+ * for (const obj of page.objects) process(obj);
453
+ * }
454
+ * ```
455
+ *
456
+ * @param prefix Only return keys beginning with this prefix (omit for all).
457
+ * @param opts Listing options (`limit` / `singlePage` are ignored here —
458
+ * control your own loop).
459
+ */
460
+ async *listObjectsPages(prefix, opts = {}) {
461
+ const seenTokens = /* @__PURE__ */ new Set();
462
+ let continuationToken = opts.continuationToken;
463
+ for (; ; ) {
464
+ const page = await this._listObjectsPage(prefix, { ...opts, continuationToken });
465
+ yield page;
466
+ const next = page.isTruncated ? page.nextContinuationToken : void 0;
467
+ if (!next || seenTokens.has(next)) break;
468
+ seenTokens.add(next);
469
+ continuationToken = next;
470
+ }
471
+ }
472
+ /** Fetch a single ListObjectsV2 page. @internal */
473
+ async _listObjectsPage(prefix, opts) {
474
+ const query = { "list-type": 2 };
475
+ if (prefix !== void 0) query.prefix = prefix;
476
+ if (!opts.recursive) query.delimiter = "/";
477
+ if (opts.continuationToken !== void 0) query["continuation-token"] = opts.continuationToken;
478
+ if (opts.startAfter !== void 0) query["start-after"] = opts.startAfter;
479
+ const resp = await this._s3Request("GET", "", { query });
480
+ if (!resp.ok) {
481
+ throw parseS3Error("ListObjectsV2", resp.status, resp.statusText, decodeText(resp.body));
482
+ }
483
+ return parseListObjectsResult(decodeText(resp.body));
484
+ }
485
+ /**
486
+ * Send a single request to the disk's S3-compatible endpoint. This reuses the
487
+ * control-plane client purely for its credential and transport — the same
488
+ * `Authorization` header is sent and verified by the same code server-side —
489
+ * pointed at the S3 host. Returns the response status and fully-buffered body
490
+ * so callers can inspect both regardless of the verb used.
491
+ *
492
+ * The S3 routes (`/{diskId}/{key}` for objects, `/{diskId}` for the bucket)
493
+ * are not part of the typed control-plane API, so the path and per-request
494
+ * options are passed untyped. An empty `key` targets the bucket itself (used
495
+ * by listObjects).
496
+ *
497
+ * @internal
498
+ */
499
+ async _s3Request(method, key, opts = {}) {
500
+ if (!this._s3BaseUrl) {
501
+ throw new Error(
502
+ "S3 base URL not configured. Pass s3BaseUrl to new Archil({...}) or set ARCHIL_S3_BASE_URL."
503
+ );
504
+ }
505
+ const call = this._client[method];
506
+ const trimmedKey = key.replace(/^\//, "");
507
+ const encodedKey = trimmedKey.split("/").map(encodeURIComponent).join("/");
508
+ const path = encodedKey ? `/${this.id}/${encodedKey}` : `/${this.id}`;
509
+ const { error, response } = await call(path, {
510
+ baseUrl: this._s3BaseUrl,
511
+ parseAs: "stream",
512
+ ...opts.query ? { params: { query: opts.query } } : {},
513
+ // fetch accepts string / Uint8Array / ArrayBuffer bodies directly; pass it
514
+ // through unchanged (no Node Buffer in the data path).
515
+ ...opts.body !== void 0 ? { body: opts.body, bodySerializer: (b) => b } : {},
516
+ ...opts.contentType ? { headers: { "Content-Type": opts.contentType } } : {}
517
+ });
518
+ if (!response.ok) {
519
+ const message = typeof error === "string" ? error : error ? JSON.stringify(error) : "";
520
+ return {
521
+ ok: false,
522
+ status: response.status,
523
+ statusText: response.statusText,
524
+ headers: response.headers,
525
+ body: new TextEncoder().encode(message)
526
+ };
527
+ }
528
+ const bytes = method === "GET" ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array(0);
529
+ return {
530
+ ok: true,
531
+ status: response.status,
532
+ statusText: response.statusText,
533
+ headers: response.headers,
534
+ body: bytes
535
+ };
536
+ }
220
537
  /**
221
538
  * Connect to this disk's data plane via the native ArchilClient.
222
539
  *
@@ -225,13 +542,15 @@ var Disk = class {
225
542
  async mount(opts) {
226
543
  let ArchilClient;
227
544
  try {
228
- const base = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : pathToFileURL(__filename).href;
229
- const nativeRequire = createRequire(base);
230
- const native = nativeRequire("@archildata/native");
231
- ArchilClient = native.ArchilClient;
545
+ const nativeSpecifier = "@archildata/native";
546
+ const native = await import(nativeSpecifier);
547
+ ArchilClient = native.ArchilClient ?? native.default?.ArchilClient;
548
+ if (!ArchilClient) {
549
+ throw new Error("@archildata/native did not export ArchilClient");
550
+ }
232
551
  } catch {
233
552
  throw new Error(
234
- "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly."
553
+ "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly. mount() is not supported in browsers."
235
554
  );
236
555
  }
237
556
  return ArchilClient.connect({
@@ -244,6 +563,34 @@ var Disk = class {
244
563
  });
245
564
  }
246
565
  };
566
+ function optionalString(value) {
567
+ return value === void 0 || value === null ? void 0 : String(value);
568
+ }
569
+ function optionalDate(value) {
570
+ return value === void 0 || value === null ? void 0 : new Date(String(value));
571
+ }
572
+ function decodeText(bytes) {
573
+ return new TextDecoder().decode(bytes);
574
+ }
575
+ function parseListObjectsResult(xml) {
576
+ const root = parseXml(xml).ListBucketResult ?? {};
577
+ const contents = root.Contents ?? [];
578
+ const objects = contents.map((c) => ({
579
+ key: String(c.Key ?? ""),
580
+ size: Number(c.Size ?? 0),
581
+ etag: optionalString(c.ETag),
582
+ lastModified: optionalDate(c.LastModified)
583
+ }));
584
+ const commonPrefixes = (root.CommonPrefixes ?? []).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
585
+ return {
586
+ objects,
587
+ commonPrefixes,
588
+ isTruncated: root.IsTruncated === "true" || root.IsTruncated === true,
589
+ nextContinuationToken: optionalString(root.NextContinuationToken),
590
+ keyCount: root.KeyCount !== void 0 ? Number(root.KeyCount) : objects.length,
591
+ prefix: optionalString(root.Prefix)
592
+ };
593
+ }
247
594
 
248
595
  // src/disks.ts
249
596
  var Disks = class {
@@ -252,9 +599,12 @@ var Disks = class {
252
599
  /** @internal */
253
600
  _region;
254
601
  /** @internal */
255
- constructor(client, region) {
602
+ _s3BaseUrl;
603
+ /** @internal */
604
+ constructor(client, region, s3BaseUrl) {
256
605
  this._client = client;
257
606
  this._region = region;
607
+ this._s3BaseUrl = s3BaseUrl;
258
608
  }
259
609
  async list(opts) {
260
610
  const data = await unwrap(
@@ -263,7 +613,7 @@ var Disks = class {
263
613
  })
264
614
  );
265
615
  return data.map(
266
- (d) => new Disk(d, this._client, this._region)
616
+ (d) => new Disk(d, this._client, this._region, this._s3BaseUrl)
267
617
  );
268
618
  }
269
619
  async get(id) {
@@ -272,7 +622,7 @@ var Disks = class {
272
622
  params: { path: { id } }
273
623
  })
274
624
  );
275
- return new Disk(data, this._client, this._region);
625
+ return new Disk(data, this._client, this._region, this._s3BaseUrl);
276
626
  }
277
627
  /**
278
628
  * Create a new disk with an auto-generated mount token.
@@ -332,6 +682,9 @@ var Tokens = class {
332
682
  };
333
683
 
334
684
  // src/archil.ts
685
+ function envVar(name) {
686
+ return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
687
+ }
335
688
  function isExecMountSpec(m) {
336
689
  return typeof m === "object" && m !== null && "disk" in m;
337
690
  }
@@ -344,21 +697,23 @@ var Archil = class {
344
697
  /** @internal */
345
698
  _client;
346
699
  constructor(opts = {}) {
347
- const apiKey = opts.apiKey ?? process.env.ARCHIL_API_KEY;
348
- const region = opts.region ?? process.env.ARCHIL_REGION;
700
+ const apiKey = opts.apiKey ?? envVar("ARCHIL_API_KEY");
701
+ const region = opts.region ?? envVar("ARCHIL_REGION");
349
702
  if (!apiKey) {
350
703
  throw new Error("Missing API key: pass apiKey in options or set ARCHIL_API_KEY environment variable");
351
704
  }
352
705
  if (!region) {
353
706
  throw new Error("Missing region: pass region in options or set ARCHIL_REGION environment variable");
354
707
  }
708
+ const controlBaseUrl = opts.baseUrl ?? resolveBaseUrl(region);
355
709
  const client = createApiClient({
356
710
  apiKey,
357
711
  region,
358
- baseUrl: opts.baseUrl
712
+ baseUrl: controlBaseUrl
359
713
  });
714
+ const s3BaseUrl = opts.s3BaseUrl ?? envVar("ARCHIL_S3_BASE_URL") ?? deriveS3BaseUrl(controlBaseUrl);
360
715
  this._client = client;
361
- this.disks = new Disks(client, region);
716
+ this.disks = new Disks(client, region, s3BaseUrl);
362
717
  this.tokens = new Tokens(client);
363
718
  }
364
719
  /**
@@ -425,9 +780,13 @@ function exec(opts) {
425
780
  export {
426
781
  Archil,
427
782
  ArchilApiError,
783
+ ArchilError,
784
+ ArchilS3Error,
428
785
  Disk,
429
786
  Disks,
430
787
  Tokens,
788
+ USER_AGENT,
789
+ VERSION,
431
790
  configure,
432
791
  createApiKey,
433
792
  createDisk,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "Pure-JS client and CLI for Archil disks",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -23,16 +23,19 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "generate": "openapi-typescript ../../api/controlplane/openapi.yaml -o src/generated/openapi.d.ts",
26
- "build": "npm run generate && tsup src/index.ts --format cjs,esm --dts && tsup bin/cli.ts --format esm --outDir dist --no-splitting",
27
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
28
- "typecheck": "npm run generate && tsc --noEmit"
26
+ "build": "npm run generate && tsup",
27
+ "dev": "tsup --watch",
28
+ "typecheck": "npm run generate && tsc --noEmit",
29
+ "test": "node --test"
29
30
  },
30
31
  "dependencies": {
31
32
  "commander": "^14.0.3",
33
+ "fast-xml-parser": "^4.5.1",
32
34
  "openapi-fetch": "^0.13.5"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@types/node": "^20.19.33",
38
+ "esbuild": "^0.27.7",
36
39
  "openapi-typescript": "^7.6.1",
37
40
  "tsup": "^8.4.0",
38
41
  "typescript": "^5.9.3"