@chrtco/sdk 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,6 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Chrt: () => Chrt,
24
24
  ChrtError: () => ChrtError,
25
+ OfflineError: () => OfflineError,
26
+ OfflineStyleError: () => OfflineStyleError,
27
+ buildOfflineStyle: () => buildOfflineStyle,
25
28
  paginate: () => paginate,
26
29
  parseWebhook: () => parseWebhook
27
30
  });
@@ -100,6 +103,51 @@ var HttpClient = class {
100
103
  }
101
104
  throw lastError ?? new Error("CHRT API request failed");
102
105
  }
106
+ /**
107
+ * Typed wrapper for non-GET JSON requests. Returns the parsed body and HTTP
108
+ * status together so callers (e.g. offline endpoints) can map error bodies
109
+ * to typed errors without throwing at the HTTP layer. Auth defaults to
110
+ * `x-api-key` header; pass `bearerToken` to swap to `Authorization: Bearer`.
111
+ * No retry — the offline flow owns its own retry logic.
112
+ */
113
+ async postJson(path, body, opts) {
114
+ const url = new URL(`${this.baseUrl}${path}`);
115
+ const headers = { "content-type": "application/json" };
116
+ if (opts?.bearerToken) {
117
+ headers["authorization"] = `Bearer ${opts.bearerToken}`;
118
+ } else {
119
+ headers["x-api-key"] = this.apiKey;
120
+ }
121
+ const res = await fetch(url.toString(), {
122
+ method: "POST",
123
+ headers,
124
+ body: JSON.stringify(body),
125
+ signal: opts?.signal
126
+ });
127
+ let parsed = null;
128
+ try {
129
+ parsed = await res.json();
130
+ } catch {
131
+ parsed = null;
132
+ }
133
+ return { ok: res.ok, status: res.status, body: parsed };
134
+ }
135
+ /** GET with Bearer auth (for offline-worker endpoints that require a JWT). */
136
+ async getJsonWithBearer(path, bearerToken, signal) {
137
+ const url = new URL(`${this.baseUrl}${path}`);
138
+ const res = await fetch(url.toString(), {
139
+ method: "GET",
140
+ headers: { authorization: `Bearer ${bearerToken}` },
141
+ signal
142
+ });
143
+ let parsed = null;
144
+ try {
145
+ parsed = await res.json();
146
+ } catch {
147
+ parsed = null;
148
+ }
149
+ return { ok: res.ok, status: res.status, body: parsed };
150
+ }
103
151
  };
104
152
  function sleep(ms) {
105
153
  return new Promise((r) => setTimeout(r, ms));
@@ -202,7 +250,748 @@ var QueryEndpoint = class {
202
250
  options.signal
203
251
  );
204
252
  }
253
+ // ── [CHRTquery] Staged — uncomment when serve-query/route is live ──
254
+ //
255
+ // /** Discover ENC cells along a route LineString */
256
+ // async alongRoute(options: RouteQueryOptions): Promise<RouteQueryResponse> {
257
+ // const route = {
258
+ // type: "LineString" as const,
259
+ // coordinates: options.route,
260
+ // };
261
+ // return this.http.request<RouteQueryResponse>(
262
+ // "/query/route",
263
+ // {
264
+ // route: JSON.stringify(route),
265
+ // buffer: options.buffer,
266
+ // source: options.source,
267
+ // scale_band: options.scaleBand,
268
+ // limit: options.limit,
269
+ // },
270
+ // options.signal,
271
+ // );
272
+ // }
273
+ // ── [CHRTquery] Staged — uncomment when serve-query/route/features is live ──
274
+ //
275
+ // /** Find individual features along a route LineString */
276
+ // async featuresAlongRoute(options: RouteFeatureQueryOptions): Promise<RouteFeatureQueryResponse> {
277
+ // const route = {
278
+ // type: "LineString" as const,
279
+ // coordinates: options.route,
280
+ // };
281
+ // return this.http.request<RouteFeatureQueryResponse>(
282
+ // "/query/route/features",
283
+ // {
284
+ // route: JSON.stringify(route),
285
+ // buffer: options.buffer,
286
+ // layer: options.layer,
287
+ // limit: options.limit,
288
+ // offset: options.offset,
289
+ // },
290
+ // options.signal,
291
+ // );
292
+ // }
293
+ };
294
+
295
+ // src/endpoints/offline-errors.ts
296
+ var OfflineError = class extends Error {
297
+ code;
298
+ status;
299
+ detail;
300
+ constructor(code, message, status, detail) {
301
+ super(message);
302
+ this.name = "OfflineError";
303
+ this.code = code;
304
+ this.status = status;
305
+ this.detail = detail;
306
+ }
307
+ };
308
+ function mapServerError(body, status) {
309
+ const b = body ?? {};
310
+ const raw = typeof b.code === "string" ? b.code : typeof b.error === "string" ? b.error : "";
311
+ switch (raw) {
312
+ case "offline_budget_exceeded":
313
+ return {
314
+ code: "budget_exceeded",
315
+ detail: {
316
+ budget_bytes: b.budget_bytes,
317
+ required_bytes: b.required_bytes,
318
+ over_by_bytes: b.over_by_bytes,
319
+ remaining_bytes: b.remaining_bytes
320
+ }
321
+ };
322
+ case "offline_not_enabled":
323
+ return { code: "offline_not_enabled" };
324
+ case "region_not_licensed":
325
+ return {
326
+ code: "coverage_denied",
327
+ detail: { requested: b.requested, allowed: b.allowed }
328
+ };
329
+ case "no_offline_builds":
330
+ case "no_offline_build":
331
+ return { code: "region_not_available", detail: { detail: b.detail, region: b.region } };
332
+ case "invalid_api_key":
333
+ case "missing_auth":
334
+ return { code: "invalid_api_key" };
335
+ case "tile_quota_exceeded":
336
+ return { code: "quota_exceeded" };
337
+ case "rate_limited":
338
+ return {
339
+ code: "quota_exceeded",
340
+ detail: { limit: b.limit, period_seconds: b.period_seconds }
341
+ };
342
+ case "revoked":
343
+ return { code: "invalid_api_key", detail: { reason: "revoked" } };
344
+ }
345
+ if (status === 401) return { code: "invalid_api_key" };
346
+ if (status === 403) return { code: "coverage_denied" };
347
+ if (status === 429) return { code: "quota_exceeded" };
348
+ if (status === 503) return { code: "region_not_available" };
349
+ return { code: "unexpected_response", detail: { raw_error: raw } };
350
+ }
351
+
352
+ // src/endpoints/offline-token-manager.ts
353
+ var REFRESH_MARGIN_SECONDS = 30;
354
+ function now() {
355
+ return Math.floor(Date.now() / 1e3);
356
+ }
357
+ var OfflineTokenManager = class {
358
+ constructor(http) {
359
+ this.http = http;
360
+ }
361
+ cache = /* @__PURE__ */ new Map();
362
+ pending = /* @__PURE__ */ new Map();
363
+ /**
364
+ * Get a fresh-enough token for the caller's device + requested regions.
365
+ * If `regions` is omitted, the mint function defaults to the key's full
366
+ * `allowed_sources`.
367
+ */
368
+ async getToken(deviceId, regions) {
369
+ if (!deviceId) {
370
+ throw new OfflineError("missing_device_id", "deviceId is required for offline operations");
371
+ }
372
+ const key = this.cacheKey(deviceId, regions);
373
+ const cached = this.cache.get(key);
374
+ if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS) {
375
+ return cached;
376
+ }
377
+ const inflight = this.pending.get(key);
378
+ if (inflight) return inflight;
379
+ const promise = this.mintFresh(deviceId, regions).finally(() => {
380
+ this.pending.delete(key);
381
+ });
382
+ this.pending.set(key, promise);
383
+ const token = await promise;
384
+ this.cache.set(key, token);
385
+ return token;
386
+ }
387
+ /** Force-evict the cached token for a (deviceId, regions) pair. Used after a 401. */
388
+ invalidate(deviceId, regions) {
389
+ this.cache.delete(this.cacheKey(deviceId, regions));
390
+ }
391
+ cacheKey(deviceId, regions) {
392
+ const tail = regions && regions.length > 0 ? [...regions].sort().join(",") : "*";
393
+ return `${deviceId}::${tail}`;
394
+ }
395
+ async mintFresh(deviceId, regions) {
396
+ const body = { device_id: deviceId };
397
+ if (regions && regions.length > 0) body.regions = regions;
398
+ const res = await this.http.postJson("/mint/offline-token", body);
399
+ if (!res.ok) {
400
+ const mapped = mapServerError(res.body, res.status);
401
+ throw new OfflineError(
402
+ mapped.code,
403
+ `mint-offline-token failed: HTTP ${res.status}`,
404
+ res.status,
405
+ mapped.detail
406
+ );
407
+ }
408
+ const body2 = res.body ?? {};
409
+ const regionsArr = Array.isArray(body2.regions) ? body2.regions : [];
410
+ const buildTags = {};
411
+ for (const r of regionsArr) {
412
+ if (r && typeof r === "object") {
413
+ const row = r;
414
+ if (typeof row.region === "string" && typeof row.build_tag === "string") {
415
+ buildTags[row.region] = row.build_tag;
416
+ }
417
+ }
418
+ }
419
+ if (typeof body2.token !== "string" || typeof body2.expires_at !== "number" || typeof body2.kid !== "string" || typeof body2.offline_until !== "number" || typeof body2.grace_period_days !== "number") {
420
+ throw new OfflineError(
421
+ "unexpected_response",
422
+ "mint-offline-token returned malformed body",
423
+ res.status
424
+ );
425
+ }
426
+ return {
427
+ token: body2.token,
428
+ expiresAt: body2.expires_at,
429
+ kid: body2.kid,
430
+ offlineUntil: body2.offline_until,
431
+ buildTags,
432
+ gracePeriodDays: body2.grace_period_days
433
+ };
434
+ }
435
+ };
436
+
437
+ // src/endpoints/offline-style.ts
438
+ var OfflineStyleError = class extends Error {
439
+ code;
440
+ constructor(message, code) {
441
+ super(message);
442
+ this.name = "OfflineStyleError";
443
+ this.code = code;
444
+ }
445
+ };
446
+ function buildOfflineStyle(onlineStyle, mbtilesPath, opts = {}) {
447
+ if (!onlineStyle || typeof onlineStyle !== "object") {
448
+ throw new OfflineStyleError("onlineStyle is required", "invalid_style");
449
+ }
450
+ if (!onlineStyle.sources || typeof onlineStyle.sources !== "object") {
451
+ throw new OfflineStyleError("style has no sources", "no_sources");
452
+ }
453
+ if (!mbtilesPath || typeof mbtilesPath !== "string") {
454
+ throw new OfflineStyleError("mbtilesPath is required", "invalid_path");
455
+ }
456
+ const platform = opts.platform ?? "ios";
457
+ const scheme = platform === "desktop" ? "file" : "mbtiles";
458
+ const url = `${scheme}://${mbtilesPath}`;
459
+ let sourceName;
460
+ if (opts.sourceName) {
461
+ if (!(opts.sourceName in onlineStyle.sources)) {
462
+ throw new OfflineStyleError(
463
+ `source "${opts.sourceName}" not found in style`,
464
+ "source_not_found"
465
+ );
466
+ }
467
+ sourceName = opts.sourceName;
468
+ } else {
469
+ const vectorSources = Object.entries(onlineStyle.sources).filter(
470
+ ([, src2]) => src2 && typeof src2 === "object" && src2.type === "vector"
471
+ );
472
+ if (vectorSources.length === 0) {
473
+ throw new OfflineStyleError(
474
+ "no vector sources in style \u2014 pass `opts.sourceName` to override",
475
+ "no_vector_source"
476
+ );
477
+ }
478
+ if (vectorSources.length > 1) {
479
+ throw new OfflineStyleError(
480
+ `${vectorSources.length} vector sources in style \u2014 pass \`opts.sourceName\``,
481
+ "ambiguous_source"
482
+ );
483
+ }
484
+ sourceName = vectorSources[0][0];
485
+ }
486
+ const cloned = JSON.parse(JSON.stringify(onlineStyle));
487
+ const src = cloned.sources[sourceName];
488
+ src.tiles = [url];
489
+ delete src.url;
490
+ return cloned;
491
+ }
492
+
493
+ // src/endpoints/offline-manifest.ts
494
+ var CURRENT_VERSION = 1;
495
+ function emptyManifest() {
496
+ return { version: CURRENT_VERSION, budget_bytes: null, regions: {} };
497
+ }
498
+ async function readManifest(manifestPath, fs) {
499
+ if (!manifestPath) return emptyManifest();
500
+ if (!await fs.exists(manifestPath)) return emptyManifest();
501
+ const raw = await fs.readFile(manifestPath);
502
+ const text = new TextDecoder().decode(raw);
503
+ let parsed;
504
+ try {
505
+ parsed = JSON.parse(text);
506
+ } catch {
507
+ throw new OfflineError(
508
+ "unexpected_response",
509
+ `offline manifest at ${manifestPath} is not valid JSON`
510
+ );
511
+ }
512
+ if (!parsed || typeof parsed !== "object") {
513
+ throw new OfflineError("unexpected_response", `offline manifest shape invalid`);
514
+ }
515
+ const m = parsed;
516
+ if (m.version !== CURRENT_VERSION) {
517
+ throw new OfflineError(
518
+ "unexpected_response",
519
+ `offline manifest version mismatch: got ${String(m.version)}, expected ${CURRENT_VERSION}`
520
+ );
521
+ }
522
+ return {
523
+ version: CURRENT_VERSION,
524
+ budget_bytes: typeof m.budget_bytes === "number" ? m.budget_bytes : null,
525
+ regions: m.regions && typeof m.regions === "object" ? m.regions : {}
526
+ };
527
+ }
528
+ async function writeManifest(manifestPath, fs, manifest) {
529
+ const text = JSON.stringify(manifest, null, 2);
530
+ const bytes = new TextEncoder().encode(text);
531
+ await fs.writeFile(manifestPath, bytes);
532
+ }
533
+ function usedBytes(manifest, excludeRegion) {
534
+ let total = 0;
535
+ for (const [region, entry] of Object.entries(manifest.regions)) {
536
+ if (region === excludeRegion) continue;
537
+ total += entry.bytes;
538
+ }
539
+ return total;
540
+ }
541
+ function requireManifestPath(manifestPath) {
542
+ if (!manifestPath) {
543
+ throw new OfflineError(
544
+ "missing_manifest_path",
545
+ "ChrtConfig.manifestPath is required for this operation"
546
+ );
547
+ }
548
+ return manifestPath;
549
+ }
550
+
551
+ // src/endpoints/offline.ts
552
+ var PROGRESS_MIN_BYTES = 1024 * 1024;
553
+ var PROGRESS_MIN_FRACTION = 0.01;
554
+ var OfflineEndpoint = class {
555
+ constructor(http, deviceId, manifestPath) {
556
+ this.http = http;
557
+ this.deviceId = deviceId;
558
+ this.manifestPath = manifestPath;
559
+ this.tokenManager = new OfflineTokenManager(http);
560
+ }
561
+ tokenManager;
562
+ /** List regions downloadable under the caller's API key. */
563
+ async list() {
564
+ const token = await this.requireToken();
565
+ const res = await this.http.getJsonWithBearer(
566
+ "/offline/regions",
567
+ token.token
568
+ );
569
+ if (!res.ok) {
570
+ const mapped = mapServerError(res.body, res.status);
571
+ throw new OfflineError(
572
+ mapped.code,
573
+ `offline.list failed: HTTP ${res.status}`,
574
+ res.status,
575
+ mapped.detail
576
+ );
577
+ }
578
+ const arr = Array.isArray(res.body?.regions) ? res.body.regions : [];
579
+ const out = [];
580
+ for (const r of arr) {
581
+ if (!r || typeof r !== "object") continue;
582
+ const row = r;
583
+ if (typeof row.region === "string" && typeof row.build_id === "string" && typeof row.build_tag === "string" && typeof row.total_bytes === "number") {
584
+ out.push({
585
+ region: row.region,
586
+ build_id: row.build_id,
587
+ build_tag: row.build_tag,
588
+ total_bytes: row.total_bytes,
589
+ issued_at: typeof row.issued_at === "number" ? row.issued_at : null
590
+ });
591
+ }
592
+ }
593
+ return out;
594
+ }
595
+ /**
596
+ * Download an MBTiles bundle for `region` to `opts.path`. Resumable via a
597
+ * `<path>.partial` sidecar on interrupt. SHA-256 verified before rename.
598
+ */
599
+ async download(region, opts) {
600
+ return this.downloadInternal(region, opts, 0);
601
+ }
602
+ /** Delete local file and, if manifest is configured, drop its row + heartbeat. */
603
+ async remove(region, opts) {
604
+ if (await opts.fs.exists(opts.path)) {
605
+ await opts.fs.unlink(opts.path);
606
+ }
607
+ const partial = `${opts.path}.partial`;
608
+ if (await opts.fs.exists(partial)) {
609
+ await opts.fs.unlink(partial);
610
+ }
611
+ if (this.manifestPath) {
612
+ const manifest = await readManifest(this.manifestPath, opts.fs);
613
+ if (manifest.regions[region]) {
614
+ delete manifest.regions[region];
615
+ await writeManifest(this.manifestPath, opts.fs, manifest);
616
+ }
617
+ const regions = Object.keys(manifest.regions);
618
+ const total = usedBytes(manifest);
619
+ try {
620
+ await this.heartbeat({ regions, total_bytes: total, fs: opts.fs });
621
+ } catch {
622
+ }
623
+ }
624
+ }
625
+ /**
626
+ * Pure helper — rewrites the named (or auto-detected) vector source in
627
+ * `onlineStyle` to point at a local MBTiles file. Layers/paint/glyphs
628
+ * untouched. Use when you already have an online style (e.g. generated
629
+ * client-side via the React app's atlas) and want the offline variant.
630
+ *
631
+ * Equivalent to importing `buildOfflineStyle` directly — exposed on the
632
+ * endpoint for discoverability.
633
+ */
634
+ style(onlineStyle, mbtilesPath, opts) {
635
+ return buildOfflineStyle(onlineStyle, mbtilesPath, opts);
636
+ }
637
+ /**
638
+ * Fetch a pre-baked offline style for `region` from
639
+ * `GET /v1/offline/styles/{region}`. The worker's response already has the
640
+ * source rewritten to `mbtiles://` (default) or `file://`, so the returned
641
+ * `style` is ready to hand directly to MapLibre.
642
+ *
643
+ * Recommended for native MLN clients (iOS/Android/desktop) that don't have
644
+ * the JS atlas available. JS/web SDK consumers can equivalently generate
645
+ * the online style themselves and call `chrt.offline.style(...)`.
646
+ */
647
+ async fetchStyle(region, opts = {}) {
648
+ const token = await this.requireToken([region]);
649
+ const palette = opts.palette ?? "DAY";
650
+ const protocol = opts.protocol ?? "mbtiles";
651
+ const path = `/offline/styles/${encodeURIComponent(region)}?palette=${palette}&protocol=${protocol}`;
652
+ const res = await this.http.getJsonWithBearer(
653
+ path,
654
+ token.token
655
+ );
656
+ if (!res.ok) {
657
+ const mapped = mapServerError(res.body, res.status);
658
+ throw new OfflineError(
659
+ mapped.code,
660
+ `offline.fetchStyle failed: HTTP ${res.status}`,
661
+ res.status,
662
+ mapped.detail
663
+ );
664
+ }
665
+ if (!res.body || typeof res.body !== "object" || !res.body.style) {
666
+ throw new OfflineError(
667
+ "unexpected_response",
668
+ "offline.fetchStyle: malformed response (missing `style`)",
669
+ res.status
670
+ );
671
+ }
672
+ return res.body;
673
+ }
674
+ /**
675
+ * Set the local storage budget in bytes. Requires `manifestPath`. Enforced
676
+ * at the start of each `download()`. A budget smaller than current usage
677
+ * does not auto-prune; it only refuses new downloads until the customer
678
+ * calls `remove()`.
679
+ */
680
+ async setStorageBudget(bytes, fs) {
681
+ if (!Number.isFinite(bytes) || bytes < 0) {
682
+ throw new OfflineError("unexpected_response", `invalid budget: ${bytes}`);
683
+ }
684
+ const path = requireManifestPath(this.manifestPath);
685
+ const manifest = await readManifest(path, fs);
686
+ manifest.budget_bytes = bytes;
687
+ await writeManifest(path, fs, manifest);
688
+ }
689
+ /**
690
+ * Send a heartbeat. If `opts.regions` / `opts.total_bytes` are omitted and
691
+ * a manifest is configured, values are derived from it. v1 is manual-call
692
+ * only; auto-scheduling is a follow-up phase.
693
+ */
694
+ async heartbeat(opts) {
695
+ const deviceId = this.requireDeviceId();
696
+ let regions = opts?.regions;
697
+ let totalBytes = opts?.total_bytes;
698
+ if ((regions === void 0 || totalBytes === void 0) && this.manifestPath && opts?.fs) {
699
+ const manifest = await readManifest(this.manifestPath, opts.fs);
700
+ if (regions === void 0) regions = Object.keys(manifest.regions);
701
+ if (totalBytes === void 0) totalBytes = usedBytes(manifest);
702
+ }
703
+ if (regions === void 0) regions = [];
704
+ if (totalBytes === void 0) totalBytes = 0;
705
+ const token = await this.tokenManager.getToken(deviceId);
706
+ const body = {
707
+ device_id: deviceId,
708
+ regions,
709
+ total_bytes: totalBytes,
710
+ ...opts?.sdk_version ? { sdk_version: opts.sdk_version } : {},
711
+ ...opts?.platform ? { platform: opts.platform } : {}
712
+ };
713
+ const res = await this.http.postJson("/offline/heartbeat", body, {
714
+ bearerToken: token.token
715
+ });
716
+ if (!res.ok) {
717
+ const mapped = mapServerError(res.body, res.status);
718
+ throw new OfflineError(
719
+ mapped.code,
720
+ `offline.heartbeat failed: HTTP ${res.status}`,
721
+ res.status,
722
+ mapped.detail
723
+ );
724
+ }
725
+ }
726
+ // ── Internals ────────────────────────────────────────────────────────────
727
+ requireDeviceId() {
728
+ if (!this.deviceId) {
729
+ throw new OfflineError(
730
+ "missing_device_id",
731
+ "ChrtConfig.deviceId is required for offline operations"
732
+ );
733
+ }
734
+ return this.deviceId;
735
+ }
736
+ async requireToken(regions) {
737
+ const deviceId = this.requireDeviceId();
738
+ return this.tokenManager.getToken(deviceId, regions);
739
+ }
740
+ async fetchBundle(region, bearerToken) {
741
+ const res = await this.http.getJsonWithBearer(
742
+ `/offline/bundles/${encodeURIComponent(region)}`,
743
+ bearerToken
744
+ );
745
+ if (!res.ok) {
746
+ const mapped = mapServerError(res.body, res.status);
747
+ throw new OfflineError(
748
+ mapped.code,
749
+ `offline.download bundle fetch failed: HTTP ${res.status}`,
750
+ res.status,
751
+ mapped.detail
752
+ );
753
+ }
754
+ const b = res.body ?? {};
755
+ const missingFields = [];
756
+ const field = (k) => b[k];
757
+ for (const k of ["signed_url", "bytes", "build_id", "build_tag", "expires_at"]) {
758
+ if (field(k) === void 0) missingFields.push(k);
759
+ }
760
+ if (missingFields.length > 0) {
761
+ throw new OfflineError(
762
+ "unexpected_response",
763
+ `bundle response missing fields: ${missingFields.join(",")}`,
764
+ res.status
765
+ );
766
+ }
767
+ if (typeof field("sha256") !== "string" || field("sha256").length === 0) {
768
+ throw new OfflineError(
769
+ "sha_missing",
770
+ "bundle response did not include sha256; cannot verify integrity",
771
+ res.status
772
+ );
773
+ }
774
+ return {
775
+ region: typeof field("region") === "string" ? field("region") : region,
776
+ build_id: field("build_id"),
777
+ build_tag: field("build_tag"),
778
+ signed_url: field("signed_url"),
779
+ expires_at: field("expires_at"),
780
+ bytes: field("bytes"),
781
+ sha256: field("sha256").toLowerCase(),
782
+ offline_until: typeof field("offline_until") === "number" ? field("offline_until") : 0,
783
+ heartbeat_after: typeof field("heartbeat_after") === "number" ? field("heartbeat_after") : 0,
784
+ server_time: typeof field("server_time") === "number" ? field("server_time") : 0
785
+ };
786
+ }
787
+ async downloadInternal(region, opts, retryDepth) {
788
+ const { path, fs, onProgress, signal } = opts;
789
+ const partialPath = `${path}.partial`;
790
+ const token = await this.requireToken([region]);
791
+ const bundle = await this.fetchBundle(region, token.token);
792
+ if (this.manifestPath) {
793
+ const manifest = await readManifest(this.manifestPath, fs);
794
+ if (manifest.budget_bytes !== null) {
795
+ const currentUsed = usedBytes(manifest, region);
796
+ const projected = currentUsed + bundle.bytes;
797
+ if (projected > manifest.budget_bytes) {
798
+ throw new OfflineError(
799
+ "budget_exceeded",
800
+ `offline storage budget would be exceeded`,
801
+ void 0,
802
+ {
803
+ budget_bytes: manifest.budget_bytes,
804
+ used_bytes: currentUsed,
805
+ required_bytes: bundle.bytes,
806
+ over_by_bytes: projected - manifest.budget_bytes
807
+ }
808
+ );
809
+ }
810
+ }
811
+ }
812
+ let startByte = 0;
813
+ let resumed = false;
814
+ if (await fs.exists(partialPath)) {
815
+ const partialStat = await fs.stat(partialPath);
816
+ if (partialStat.size > bundle.bytes) {
817
+ await fs.unlink(partialPath);
818
+ } else if (partialStat.size === bundle.bytes) {
819
+ const computed = await fs.sha256(partialPath);
820
+ if (computed.toLowerCase() === bundle.sha256) {
821
+ await fs.rename(partialPath, path);
822
+ await this.persistManifestEntry(region, bundle, path, fs);
823
+ return {
824
+ path,
825
+ bytes: bundle.bytes,
826
+ sha256: bundle.sha256,
827
+ build_id: bundle.build_id,
828
+ build_tag: bundle.build_tag,
829
+ resumed: true
830
+ };
831
+ }
832
+ await fs.unlink(partialPath);
833
+ } else {
834
+ startByte = partialStat.size;
835
+ resumed = true;
836
+ }
837
+ }
838
+ const fetchInit = { method: "GET", signal };
839
+ if (startByte > 0) {
840
+ fetchInit.headers = { Range: `bytes=${startByte}-` };
841
+ }
842
+ let response;
843
+ try {
844
+ response = await fetch(bundle.signed_url, fetchInit);
845
+ } catch (err) {
846
+ if (signal?.aborted) {
847
+ throw new OfflineError(
848
+ "network_interrupted",
849
+ "download aborted before response",
850
+ void 0,
851
+ { resumable: startByte > 0, bytes_written: startByte }
852
+ );
853
+ }
854
+ throw new OfflineError(
855
+ "network_interrupted",
856
+ err instanceof Error ? err.message : "fetch failed",
857
+ void 0,
858
+ { resumable: startByte > 0, bytes_written: startByte }
859
+ );
860
+ }
861
+ if (startByte > 0 && (response.status === 403 || response.status === 404)) {
862
+ await fs.unlink(partialPath);
863
+ if (retryDepth > 0) {
864
+ throw new OfflineError(
865
+ "unexpected_response",
866
+ `signed URL still rejected after re-mint (HTTP ${response.status})`,
867
+ response.status
868
+ );
869
+ }
870
+ this.tokenManager.invalidate(this.requireDeviceId(), [region]);
871
+ return this.downloadInternal(region, opts, retryDepth + 1);
872
+ }
873
+ if (startByte > 0 && response.status !== 206) {
874
+ throw new OfflineError(
875
+ "unexpected_response",
876
+ `expected 206 Partial Content on Range request, got ${response.status}`,
877
+ response.status
878
+ );
879
+ }
880
+ if (startByte === 0 && response.status !== 200) {
881
+ throw new OfflineError(
882
+ "unexpected_response",
883
+ `expected 200 on initial GET, got ${response.status}`,
884
+ response.status
885
+ );
886
+ }
887
+ if (!response.body) {
888
+ throw new OfflineError(
889
+ "unexpected_response",
890
+ "response had no body",
891
+ response.status
892
+ );
893
+ }
894
+ await fs.mkdir(dirname(path), { recursive: true });
895
+ const writer = (await fs.writeStream(partialPath, { append: startByte > 0 })).getWriter();
896
+ let bytesDownloaded = startByte;
897
+ let lastProgressBytes = startByte;
898
+ const progressThreshold = Math.max(
899
+ PROGRESS_MIN_BYTES,
900
+ Math.floor(bundle.bytes * PROGRESS_MIN_FRACTION)
901
+ );
902
+ const emit = (final) => {
903
+ if (!onProgress) return;
904
+ if (!final && bytesDownloaded - lastProgressBytes < progressThreshold) return;
905
+ const progress = {
906
+ bytesDownloaded,
907
+ bytesTotal: bundle.bytes,
908
+ fraction: bundle.bytes === 0 ? 1 : bytesDownloaded / bundle.bytes
909
+ };
910
+ try {
911
+ onProgress(progress);
912
+ } catch {
913
+ }
914
+ lastProgressBytes = bytesDownloaded;
915
+ };
916
+ emit(true);
917
+ try {
918
+ const reader = response.body.getReader();
919
+ while (true) {
920
+ if (signal?.aborted) {
921
+ throw new OfflineError(
922
+ "network_interrupted",
923
+ "download aborted by caller",
924
+ void 0,
925
+ { resumable: true, bytes_written: bytesDownloaded }
926
+ );
927
+ }
928
+ const { done, value } = await reader.read();
929
+ if (done) break;
930
+ if (value) {
931
+ await writer.write(value);
932
+ bytesDownloaded += value.byteLength;
933
+ emit(false);
934
+ }
935
+ }
936
+ await writer.close();
937
+ } catch (err) {
938
+ try {
939
+ await writer.abort();
940
+ } catch {
941
+ }
942
+ if (err instanceof OfflineError) throw err;
943
+ throw new OfflineError(
944
+ "network_interrupted",
945
+ err instanceof Error ? err.message : "stream error",
946
+ void 0,
947
+ { resumable: true, bytes_written: bytesDownloaded }
948
+ );
949
+ }
950
+ emit(true);
951
+ const computedSha = await fs.sha256(partialPath);
952
+ if (computedSha.toLowerCase() !== bundle.sha256) {
953
+ await fs.unlink(partialPath);
954
+ throw new OfflineError(
955
+ "download_corrupt",
956
+ `SHA-256 mismatch for ${region}: expected ${bundle.sha256}, got ${computedSha}`,
957
+ void 0,
958
+ { expected: bundle.sha256, actual: computedSha }
959
+ );
960
+ }
961
+ await fs.rename(partialPath, path);
962
+ await this.persistManifestEntry(region, bundle, path, fs);
963
+ return {
964
+ path,
965
+ bytes: bundle.bytes,
966
+ sha256: bundle.sha256,
967
+ build_id: bundle.build_id,
968
+ build_tag: bundle.build_tag,
969
+ resumed
970
+ };
971
+ }
972
+ async persistManifestEntry(region, bundle, path, fs) {
973
+ if (!this.manifestPath) return;
974
+ const manifest = await readManifest(this.manifestPath, fs);
975
+ const nowSec = Math.floor(Date.now() / 1e3);
976
+ manifest.regions[region] = {
977
+ path,
978
+ bytes: bundle.bytes,
979
+ sha256: bundle.sha256,
980
+ build_id: bundle.build_id,
981
+ build_tag: bundle.build_tag,
982
+ downloaded_at: nowSec,
983
+ last_accessed_at: nowSec
984
+ };
985
+ await writeManifest(this.manifestPath, fs, manifest);
986
+ }
205
987
  };
988
+ function dirname(p) {
989
+ const i = p.lastIndexOf("/");
990
+ const j = p.lastIndexOf("\\");
991
+ const k = Math.max(i, j);
992
+ if (k <= 0) return ".";
993
+ return p.slice(0, k);
994
+ }
206
995
 
207
996
  // src/pagination.ts
208
997
  async function* paginate(fetchPage, getItems, getTotalCount, options) {
@@ -253,6 +1042,12 @@ var Chrt = class {
253
1042
  cells;
254
1043
  /** Coverage data — list available chart sources */
255
1044
  coverage;
1045
+ /**
1046
+ * CHRToffline — download MBTiles bundles for offline use.
1047
+ * Requires `deviceId` in config. Manifest-based budget enforcement
1048
+ * requires `manifestPath`.
1049
+ */
1050
+ offline;
256
1051
  featuresEp;
257
1052
  queryEp;
258
1053
  http;
@@ -264,6 +1059,7 @@ var Chrt = class {
264
1059
  this.coverage = new CoverageEndpoint(this.http);
265
1060
  this.featuresEp = new FeaturesEndpoint(this.http);
266
1061
  this.queryEp = new QueryEndpoint(this.http);
1062
+ this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
267
1063
  }
268
1064
  /* ── Convenience shortcuts ───────────────────────── */
269
1065
  /** Query GeoJSON features by bounding box */
@@ -274,6 +1070,32 @@ var Chrt = class {
274
1070
  async query(options) {
275
1071
  return this.queryEp.nearby(options);
276
1072
  }
1073
+ // ── [CHRTquery] Staged — uncomment when serve-query/route is live ──
1074
+ //
1075
+ // /** Discover ENC cells along a route */
1076
+ // async routeQuery(options: RouteQueryOptions): Promise<RouteQueryResponse> {
1077
+ // return this.queryEp.alongRoute(options);
1078
+ // }
1079
+ // ── [CHRTquery] Staged — uncomment when serve-query/route/features is live ──
1080
+ //
1081
+ // /** Find individual features along a route */
1082
+ // async routeFeatures(options: RouteFeatureQueryOptions): Promise<RouteFeatureQueryResponse> {
1083
+ // return this.queryEp.featuresAlongRoute(options);
1084
+ // }
1085
+ //
1086
+ // /** Iterate all features along a route, auto-paginating */
1087
+ // routeFeaturesIter(
1088
+ // options: Omit<RouteFeatureQueryOptions, "offset">,
1089
+ // ): AsyncIterable<GeoJsonFeature> {
1090
+ // const pageSize = options.limit ?? 100;
1091
+ // return paginate<RouteFeatureQueryResponse, GeoJsonFeature>(
1092
+ // (offset, signal) =>
1093
+ // this.routeFeatures({ ...options, limit: pageSize, offset, signal }),
1094
+ // (r) => r.features,
1095
+ // (r) => r.total_count ?? 0,
1096
+ // { pageSize, signal: options.signal },
1097
+ // );
1098
+ // }
277
1099
  /* ── Auto-pagination iterators ───────────────────── */
278
1100
  /**
279
1101
  * Iterate all features matching a bounding-box query, auto-paginating.
@@ -342,6 +1164,9 @@ var Chrt = class {
342
1164
  0 && (module.exports = {
343
1165
  Chrt,
344
1166
  ChrtError,
1167
+ OfflineError,
1168
+ OfflineStyleError,
1169
+ buildOfflineStyle,
345
1170
  paginate,
346
1171
  parseWebhook
347
1172
  });