@chrtco/sdk 0.4.0 → 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.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
|
});
|