@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/CHANGELOG.md +46 -0
- package/README.md +69 -0
- package/dist/fs-adapter-D5iwdb1Q.d.cts +33 -0
- package/dist/fs-adapter-D5iwdb1Q.d.ts +33 -0
- package/dist/index.cjs +825 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +398 -1
- package/dist/index.d.ts +398 -1
- package/dist/index.js +822 -0
- package/dist/index.js.map +1 -1
- package/dist/node/index.cjs +90 -0
- package/dist/node/index.cjs.map +1 -0
- package/dist/node/index.d.cts +5 -0
- package/dist/node/index.d.ts +5 -0
- package/dist/node/index.js +66 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +14 -6
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
|
};
|