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