@chrtco/sdk 0.7.7 → 0.8.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@chrtco/sdk` will be documented in this file.
4
4
 
5
+ ## [0.8.0] — 2026-06-30
6
+ - **Online tile rendering now works end-to-end against `tiles.chrt.co`.** `chrt.transformRequest()` mints a short-lived ES256 tile JWT from your API key (`POST {baseUrl}/mint/tile-token`), caches and refreshes it, and attaches `Authorization: Bearer` on `tiles.chrt.co/v1/tiles/{region}/…/.mvt` requests only. This replaces the previous no-op transformer that injected `x-api-key` against a removed `api.chrt.co/v1/tiles/…?key=` path.
7
+ - **New `chrt.style(opts?)`** — returns a ready-to-use IHO **S-52** MapLibre / Mapbox GL style object (`palette`: `DAY` | `DUSK` | `NIGHT`, plus `region`, `depthUnits`, and the other atlas options), with the tile source wired in. Mutate the returned object, or build a fully custom style over `buildSources()`. S-101 portrayal is on the roadmap.
8
+ - **New `chrt.warmTiles(region?)`** — optionally pre-mint the tile token to surface `region_not_licensed` / `tile_quota_exceeded` before the map loads.
9
+ - **New config options**: `region` (default `NOAA_ENC`) and `tileBaseUrl` (default `https://tiles.chrt.co`).
10
+ - **New exports**: `TileAuth`, `TileTokenError`, and the `TileTokenResponse` / `TileTokenErrorCode` / `ChrtStyleOptions` types.
11
+ - **Deprecated**: `styleUrl()` — CHRT serves no remote `style.json`; it now throws with guidance to use `style()`. `tiles.groups()` — legacy pre-consolidation per-group catalogue. `tiles.tileUrl()` now returns the real `tiles.chrt.co` URL.
12
+ - `TransformRequest.headers` is now optional (matches MapLibre; the transformer omits headers on unauthenticated passthrough).
13
+ - **Requires** the public `POST /v1/mint/tile-token` route on `api.chrt.co` (the sibling of the existing `/v1/mint/offline-token`).
14
+
5
15
  ## [0.7.0] — 2026-05-08
6
16
  - **Atlas — dominance cascade & scale-band rendering**: per-feature dominance cascade in continuous mode, cascading band fallback, band-then-class layer emission, chart-mode per-cell cascade fallback to coarser bands, dominance filter on orientation-cross-band labels.
7
17
  - **Atlas — cartographic labels**: orientation labels + typography hierarchy.
package/README.md CHANGED
@@ -37,30 +37,56 @@ const cells = await chrt.cells.list({ source: 'NOAA' });
37
37
  const cell = await chrt.cells.get('US5MA11M');
38
38
  ```
39
39
 
40
- ## Mapbox GL JS / MapLibre
40
+ ## Maps (MapLibre GL JS / Mapbox GL JS)
41
+
42
+ `chrt.style()` returns a ready-to-use IHO **S-52** nautical style; `chrt.transformRequest()`
43
+ mints a short-lived tile token from your API key and attaches it to tile requests
44
+ automatically. Examples use MapLibre — substitute `mapbox-gl` if you're already on Mapbox.
41
45
 
42
46
  ```typescript
43
- import mapboxgl from 'mapbox-gl';
47
+ import maplibregl from 'maplibre-gl';
44
48
  import { Chrt } from '@chrtco/sdk';
45
49
 
46
50
  const chrt = new Chrt('chrt_live_...');
47
51
 
48
- const map = new mapboxgl.Map({
52
+ const map = new maplibregl.Map({
49
53
  container: 'map',
50
- style: chrt.styleUrl(),
54
+ style: chrt.style({ palette: 'DAY' }), // 'DAY' | 'DUSK' | 'NIGHT'
51
55
  center: [-70.25, 43.66],
52
56
  zoom: 12,
53
57
  transformRequest: chrt.transformRequest(),
54
58
  });
55
59
  ```
56
60
 
61
+ Tiles are served from `tiles.chrt.co` and gated by a short-lived ES256 JWT. The SDK mints
62
+ it from your key, attaches it as `Authorization: Bearer` on tile requests only, and refreshes
63
+ it before expiry — you never handle tokens directly.
64
+
65
+ ### Custom styles
66
+
67
+ `chrt.style()` returns a standard MapLibre style object you can mutate. For a fully custom
68
+ look, build your own style over CHRT's vector source with `buildSources()` and keep
69
+ `chrt.transformRequest()` for auth:
70
+
71
+ ```typescript
72
+ import { buildSources } from '@chrtco/sdk';
73
+
74
+ const sources = buildSources({ region: 'NOAA_ENC' });
75
+ // sources['NOAA_ENC'].tiles → ['https://tiles.chrt.co/v1/tiles/NOAA_ENC/{z}/{x}/{y}.mvt']
76
+ // Each S-57 class (DEPARE, LIGHTS, BOYLAT, …) is a `source-layer` inside that one source.
77
+ ```
78
+
79
+ > S-101 portrayal is on the roadmap; today `chrt.style()` emits S-52.
80
+
57
81
  ## Configuration
58
82
 
59
83
  ```typescript
60
84
  const chrt = new Chrt({
61
85
  apiKey: 'chrt_live_...',
62
- baseUrl: 'https://api.chrt.co/v1', // default
63
- retries: 2, // retry on 5xx, default 2
86
+ baseUrl: 'https://api.chrt.co/v1', // default
87
+ retries: 2, // retry on 5xx, default 2
88
+ region: 'NOAA_ENC', // default tile region
89
+ tileBaseUrl: 'https://tiles.chrt.co', // override for staging tiles
64
90
  });
65
91
  ```
66
92
 
@@ -92,20 +118,27 @@ List available ENC cells.
92
118
  ### `chrt.cells.get(cellId)`
93
119
  Get metadata for a specific cell.
94
120
 
95
- ### `chrt.tiles.styleUrl()`
96
- Returns a Mapbox/MapLibre-compatible `style.json` URL.
121
+ ### `chrt.style(opts?)`
122
+ Returns a ready-to-use MapLibre/Mapbox GL **S-52** style object. Options: `palette`
123
+ (`'DAY'` | `'DUSK'` | `'NIGHT'`), `region`, `depthUnits`, plus the other atlas style options.
97
124
 
98
- ### `chrt.tiles.tileUrl(z, x, y)`
99
- Returns a single vector tile URL.
125
+ ### `chrt.transformRequest(opts?)`
126
+ Returns a `transformRequest` that mints a short-lived tile JWT from your API key and attaches
127
+ it as `Authorization: Bearer` on `tiles.chrt.co` requests only. Pass `{ region }` to override
128
+ the default region.
100
129
 
101
- ### `chrt.tiles.groups()`
102
- List available tileset groups.
130
+ ### `chrt.warmTiles(region?)`
131
+ Pre-mints the tile token so you can surface auth errors (e.g. `region_not_licensed`,
132
+ `tile_quota_exceeded`) before the map loads. Optional — `transformRequest()` warms the cache
133
+ on its own.
103
134
 
104
- ### `chrt.styleUrl()`
105
- Shortcut for `chrt.tiles.styleUrl()`.
135
+ ### `chrt.tiles.tileUrl(z, x, y)`
136
+ Single vector tile URL template at `tiles.chrt.co`. Prefer `chrt.style()`, which wires the
137
+ source for you.
106
138
 
107
- ### `chrt.transformRequest()`
108
- Returns a `transformRequest` function for Mapbox GL / MapLibre that auto-injects the API key.
139
+ ### `chrt.styleUrl()` · `chrt.tiles.groups()` — deprecated
140
+ CHRT serves no remote `style.json` — use `chrt.style()`. Tilesets were consolidated to one
141
+ source per region (2026-04), so the per-group catalogue is legacy.
109
142
 
110
143
  ## Error Handling
111
144
 
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  ChrtError: () => ChrtError,
32
32
  DEFAULT_REGION: () => DEFAULT_REGION,
33
33
  DEFAULT_TILE_BASE_URL: () => DEFAULT_TILE_BASE_URL,
34
+ DEFAULT_TILE_REGION: () => DEFAULT_TILE_REGION,
34
35
  LABELED_LAYERS: () => LABELED_LAYERS,
35
36
  LABEL_BEHAVIOR: () => LABEL_BEHAVIOR,
36
37
  NAUTICAL_SYMBOLS: () => NAUTICAL_SYMBOLS,
@@ -48,6 +49,8 @@ __export(index_exports, {
48
49
  S52_NIGHT: () => S52_NIGHT,
49
50
  SYMBOL_GROUPS: () => SYMBOL_GROUPS,
50
51
  SYMBOL_MAP: () => SYMBOL_MAP,
52
+ TileAuth: () => TileAuth,
53
+ TileTokenError: () => TileTokenError,
51
54
  applyColorTheme: () => applyColorTheme,
52
55
  bandFilterExpression: () => bandFilterExpression,
53
56
  buildLabelLayer: () => buildLabelLayer,
@@ -226,20 +229,243 @@ function sleep(ms) {
226
229
  return new Promise((r) => setTimeout(r, ms));
227
230
  }
228
231
 
232
+ // src/tile-auth.ts
233
+ var DEFAULT_TILE_REGION = "NOAA_ENC";
234
+ var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
235
+ var TileTokenError = class extends Error {
236
+ code;
237
+ status;
238
+ constructor(code, message, status) {
239
+ super(message);
240
+ this.name = "TileTokenError";
241
+ this.code = code;
242
+ this.status = status;
243
+ }
244
+ };
245
+ var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
246
+ var REFRESH_MARGIN_SECONDS = 30;
247
+ var STALE_GRACE_SECONDS = 1;
248
+ var MINT_RETRY_DELAYS_MS = [500, 1e3, 2e3];
249
+ function nowSeconds() {
250
+ return Math.floor(Date.now() / 1e3);
251
+ }
252
+ function sleep2(ms) {
253
+ return new Promise((r) => setTimeout(r, ms));
254
+ }
255
+ function mapErrorCode(raw, status) {
256
+ const known = [
257
+ "invalid_api_key",
258
+ "feature_flag_disabled",
259
+ "region_not_licensed",
260
+ "tile_quota_exceeded",
261
+ "region_not_available"
262
+ ];
263
+ if (typeof raw === "string" && known.includes(raw)) {
264
+ return raw;
265
+ }
266
+ if (status === 401) return "invalid_api_key";
267
+ if (status === 403) return "feature_flag_disabled";
268
+ if (status === 429) return "tile_quota_exceeded";
269
+ if (status === 503) return "region_not_available";
270
+ return "unexpected_response";
271
+ }
272
+ var TileAuth = class {
273
+ constructor(http, opts) {
274
+ this.http = http;
275
+ this.defaultRegion = opts?.region ?? DEFAULT_TILE_REGION;
276
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
277
+ }
278
+ cache = /* @__PURE__ */ new Map();
279
+ pending = /* @__PURE__ */ new Map();
280
+ defaultRegion;
281
+ tileBaseUrl;
282
+ lifecycleRegistered = false;
283
+ /** Mint once via the HTTP client (POST `/mint/tile-token`, `x-api-key`). */
284
+ async mintOnce(region) {
285
+ let res;
286
+ try {
287
+ res = await this.http.postJson("/mint/tile-token", { region });
288
+ } catch (err) {
289
+ throw new TileTokenError(
290
+ "network_error",
291
+ err instanceof Error ? err.message : "mint request failed"
292
+ );
293
+ }
294
+ if (!res.ok) {
295
+ const code = mapErrorCode(res.body?.error, res.status);
296
+ const msg = typeof res.body?.error === "string" ? res.body.error : `HTTP ${res.status}`;
297
+ throw new TileTokenError(code, msg, res.status);
298
+ }
299
+ const b = res.body;
300
+ if (!b || typeof b.token !== "string" || typeof b.expires_at !== "number" || typeof b.kid !== "string" || typeof b.build_tag !== "string" || typeof b.region !== "string") {
301
+ throw new TileTokenError(
302
+ "unexpected_response",
303
+ "mint-tile-token returned a malformed body",
304
+ res.status
305
+ );
306
+ }
307
+ return {
308
+ token: b.token,
309
+ expiresAt: b.expires_at,
310
+ kid: b.kid,
311
+ buildTag: b.build_tag,
312
+ region: b.region
313
+ };
314
+ }
315
+ /** Mint with exponential backoff on network errors only. */
316
+ async mintWithRetry(region) {
317
+ let lastErr;
318
+ for (let attempt = 0; attempt <= MINT_RETRY_DELAYS_MS.length; attempt++) {
319
+ try {
320
+ return await this.mintOnce(region);
321
+ } catch (err) {
322
+ lastErr = err;
323
+ if (err instanceof TileTokenError && err.code !== "network_error") throw err;
324
+ if (attempt === MINT_RETRY_DELAYS_MS.length) break;
325
+ await sleep2(MINT_RETRY_DELAYS_MS[attempt]);
326
+ }
327
+ }
328
+ throw lastErr instanceof Error ? lastErr : new TileTokenError("network_error", "mint failed");
329
+ }
330
+ scheduleRefresh(region, expiresAt) {
331
+ const delaySeconds = expiresAt - nowSeconds() - REFRESH_MARGIN_SECONDS;
332
+ if (delaySeconds <= 0) return null;
333
+ const timer = setTimeout(() => {
334
+ void this.refreshInBackground(region);
335
+ }, delaySeconds * 1e3);
336
+ timer.unref?.();
337
+ return timer;
338
+ }
339
+ async refreshInBackground(region) {
340
+ try {
341
+ const response = await this.mintWithRetry(region);
342
+ this.store(region, response);
343
+ } catch {
344
+ this.invalidate(region);
345
+ }
346
+ }
347
+ store(region, response) {
348
+ const existing = this.cache.get(region);
349
+ if (existing?.refreshTimer) clearTimeout(existing.refreshTimer);
350
+ this.cache.set(region, {
351
+ response,
352
+ refreshTimer: this.scheduleRefresh(region, response.expiresAt)
353
+ });
354
+ this.registerLifecycleHandlers();
355
+ }
356
+ /**
357
+ * Fetch (or reuse a cached) tile token for `region`. Concurrent callers for
358
+ * the same region await the same in-flight mint.
359
+ */
360
+ async getToken(region) {
361
+ const r = region ?? this.defaultRegion;
362
+ const cached = this.cache.get(r);
363
+ if (cached && cached.response.expiresAt - nowSeconds() > REFRESH_MARGIN_SECONDS) {
364
+ return cached.response;
365
+ }
366
+ const inflight = this.pending.get(r);
367
+ if (inflight) return inflight;
368
+ const promise = (async () => {
369
+ try {
370
+ const response = await this.mintWithRetry(r);
371
+ this.store(r, response);
372
+ return response;
373
+ } finally {
374
+ this.pending.delete(r);
375
+ }
376
+ })();
377
+ this.pending.set(r, promise);
378
+ return promise;
379
+ }
380
+ /** Synchronous cache peek — null if absent or within the stale grace window. */
381
+ peek(region) {
382
+ const entry = this.cache.get(region ?? this.defaultRegion);
383
+ if (!entry) return null;
384
+ if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return null;
385
+ return entry.response.token;
386
+ }
387
+ /**
388
+ * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
389
+ * `Authorization: Bearer <jwt>` only to CHRT tile URLs; style, sprite, and
390
+ * glyph URLs pass through untouched (never leak the token to third-party
391
+ * CDNs). Kicks off a background mint immediately so the cache warms within
392
+ * one round-trip; the first tile or two may go unauthenticated and be
393
+ * retried by the renderer.
394
+ */
395
+ transformRequest(region) {
396
+ const r = region ?? this.defaultRegion;
397
+ void this.getToken(r).catch(() => void 0);
398
+ return (url) => {
399
+ if (!url.startsWith(this.tileBaseUrl) || !TILE_URL_PATH.test(url)) return void 0;
400
+ const token = this.peek(r);
401
+ if (!token) return { url };
402
+ return { url, headers: { Authorization: `Bearer ${token}` } };
403
+ };
404
+ }
405
+ /** Clear the cached token + timer for a region. */
406
+ invalidate(region) {
407
+ const r = region ?? this.defaultRegion;
408
+ const entry = this.cache.get(r);
409
+ if (entry?.refreshTimer) clearTimeout(entry.refreshTimer);
410
+ this.cache.delete(r);
411
+ }
412
+ /**
413
+ * Browser-only defense against `setTimeout` freezing while a tab is
414
+ * suspended (laptop sleep, mobile background, bfcache restore): proactively
415
+ * re-mint near-expiry entries on wake. Inert under SSR / Node / Deno.
416
+ */
417
+ registerLifecycleHandlers() {
418
+ if (this.lifecycleRegistered) return;
419
+ if (typeof document === "undefined" || typeof window === "undefined") return;
420
+ const onWake = () => {
421
+ if (document.visibilityState !== "visible") return;
422
+ for (const [region, entry] of this.cache.entries()) {
423
+ if (entry.response.expiresAt - nowSeconds() >= REFRESH_MARGIN_SECONDS) continue;
424
+ void this.refreshInBackground(region);
425
+ }
426
+ };
427
+ document.addEventListener("visibilitychange", onWake);
428
+ window.addEventListener("pageshow", (e) => {
429
+ if (e.persisted) onWake();
430
+ });
431
+ this.lifecycleRegistered = true;
432
+ }
433
+ };
434
+
229
435
  // src/endpoints/tiles.ts
230
436
  var TilesEndpoint = class {
231
- constructor(http) {
437
+ constructor(http, opts) {
232
438
  this.http = http;
439
+ this.region = opts?.region ?? DEFAULT_TILE_REGION;
440
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
233
441
  }
234
- /** Full style.json URL with API key as query param */
442
+ region;
443
+ tileBaseUrl;
444
+ /**
445
+ * @deprecated CHRT has no remote `style.json` endpoint — the S-52 style is
446
+ * generated client-side. Use `chrt.style(opts)` to get a ready-to-use
447
+ * MapLibre style object instead. Calling this throws to prevent silently
448
+ * pointing a map at a dead URL.
449
+ */
235
450
  styleUrl() {
236
- return `${this.http.getBaseUrl()}/tiles/style.json?key=${this.http.getApiKey()}`;
451
+ throw new Error(
452
+ "chrt.tiles.styleUrl() is no longer supported: CHRT serves no remote style.json. Use chrt.style(opts) to generate an S-52 MapLibre style object, and chrt.transformRequest() for tile auth. See https://chrt.co/docs/vector-tiles"
453
+ );
237
454
  }
238
- /** Individual tile URL template */
239
- tileUrl(z, x, y, format = "mvt") {
240
- return `${this.http.getBaseUrl()}/tiles/${z}/${x}/${y}.${format}?key=${this.http.getApiKey()}`;
455
+ /**
456
+ * Vector tile URL template for the configured region, served by the
457
+ * tile-worker at `tiles.chrt.co`. Tiles are JWT-gated — pair this with
458
+ * `chrt.transformRequest()`, which attaches the `Authorization: Bearer`
459
+ * header. Prefer `chrt.style()`, which wires the source for you.
460
+ */
461
+ tileUrl(z, x, y) {
462
+ return `${this.tileBaseUrl}/v1/tiles/${this.region}/${z}/${x}/${y}.mvt`;
241
463
  }
242
- /** List available tileset groups */
464
+ /**
465
+ * @deprecated Tilesets were consolidated to one PMTiles per region (2026-04);
466
+ * the per-group catalogue endpoint is legacy and no longer served. Render via
467
+ * `chrt.style()` instead, which emits a single vector source per region.
468
+ */
243
469
  async groups() {
244
470
  return this.http.request("/tiles/groups");
245
471
  }
@@ -431,7 +657,7 @@ function mapServerError(body, status) {
431
657
  }
432
658
 
433
659
  // src/endpoints/offline-token-manager.ts
434
- var REFRESH_MARGIN_SECONDS = 30;
660
+ var REFRESH_MARGIN_SECONDS2 = 30;
435
661
  function now() {
436
662
  return Math.floor(Date.now() / 1e3);
437
663
  }
@@ -452,7 +678,7 @@ var OfflineTokenManager = class {
452
678
  }
453
679
  const key = this.cacheKey(deviceId, regions);
454
680
  const cached = this.cache.get(key);
455
- if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS) {
681
+ if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS2) {
456
682
  return cached;
457
683
  }
458
684
  const inflight = this.pending.get(key);
@@ -1074,47 +1300,6 @@ function dirname(p) {
1074
1300
  return p.slice(0, k);
1075
1301
  }
1076
1302
 
1077
- // src/pagination.ts
1078
- async function* paginate(fetchPage, getItems, getTotalCount, options) {
1079
- const pageSize = options?.pageSize ?? 100;
1080
- const signal = options?.signal;
1081
- let offset = 0;
1082
- while (true) {
1083
- if (signal?.aborted) return;
1084
- const response = await fetchPage(offset, signal);
1085
- const items = getItems(response);
1086
- for (const item of items) {
1087
- yield item;
1088
- }
1089
- if (items.length === 0) return;
1090
- offset += items.length;
1091
- const total = getTotalCount(response);
1092
- if (total > 0 && offset >= total) return;
1093
- }
1094
- }
1095
-
1096
- // src/webhooks.ts
1097
- var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
1098
- function parseWebhook(body) {
1099
- if (typeof body !== "object" || body === null) {
1100
- throw new Error("Invalid webhook payload: expected an object");
1101
- }
1102
- const obj = body;
1103
- if (typeof obj.id !== "string") {
1104
- throw new Error("Invalid webhook payload: missing 'id'");
1105
- }
1106
- if (typeof obj.timestamp !== "string") {
1107
- throw new Error("Invalid webhook payload: missing 'timestamp'");
1108
- }
1109
- if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
1110
- throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
1111
- }
1112
- if (typeof obj.data !== "object" || obj.data === null) {
1113
- throw new Error("Invalid webhook payload: missing 'data'");
1114
- }
1115
- return obj;
1116
- }
1117
-
1118
1303
  // src/atlas/s52-colors.ts
1119
1304
  var S52_DAY = {
1120
1305
  // Depth
@@ -9479,7 +9664,7 @@ var BUILD_MINZOOM = 3;
9479
9664
  var BUILD_MAXZOOM = 14;
9480
9665
 
9481
9666
  // src/atlas/source-registry.ts
9482
- var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
9667
+ var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
9483
9668
  var DEFAULT_REGION = "NOAA_ENC";
9484
9669
  function buildSources(opts = {}) {
9485
9670
  const mode = opts.mode ?? "pmtiles";
@@ -9492,7 +9677,7 @@ function buildSources(opts = {}) {
9492
9677
  }
9493
9678
  };
9494
9679
  }
9495
- const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
9680
+ const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
9496
9681
  return {
9497
9682
  [region]: {
9498
9683
  type: "vector",
@@ -11219,6 +11404,47 @@ function getStyleStats(style) {
11219
11404
  };
11220
11405
  }
11221
11406
 
11407
+ // src/pagination.ts
11408
+ async function* paginate(fetchPage, getItems, getTotalCount, options) {
11409
+ const pageSize = options?.pageSize ?? 100;
11410
+ const signal = options?.signal;
11411
+ let offset = 0;
11412
+ while (true) {
11413
+ if (signal?.aborted) return;
11414
+ const response = await fetchPage(offset, signal);
11415
+ const items = getItems(response);
11416
+ for (const item of items) {
11417
+ yield item;
11418
+ }
11419
+ if (items.length === 0) return;
11420
+ offset += items.length;
11421
+ const total = getTotalCount(response);
11422
+ if (total > 0 && offset >= total) return;
11423
+ }
11424
+ }
11425
+
11426
+ // src/webhooks.ts
11427
+ var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
11428
+ function parseWebhook(body) {
11429
+ if (typeof body !== "object" || body === null) {
11430
+ throw new Error("Invalid webhook payload: expected an object");
11431
+ }
11432
+ const obj = body;
11433
+ if (typeof obj.id !== "string") {
11434
+ throw new Error("Invalid webhook payload: missing 'id'");
11435
+ }
11436
+ if (typeof obj.timestamp !== "string") {
11437
+ throw new Error("Invalid webhook payload: missing 'timestamp'");
11438
+ }
11439
+ if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
11440
+ throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
11441
+ }
11442
+ if (typeof obj.data !== "object" || obj.data === null) {
11443
+ throw new Error("Invalid webhook payload: missing 'data'");
11444
+ }
11445
+ return obj;
11446
+ }
11447
+
11222
11448
  // src/index.ts
11223
11449
  var Chrt = class {
11224
11450
  /** Tile endpoints — styleUrl, tileUrl, groups */
@@ -11236,15 +11462,22 @@ var Chrt = class {
11236
11462
  featuresEp;
11237
11463
  queryEp;
11238
11464
  http;
11465
+ tileAuth;
11466
+ region;
11467
+ tileBaseUrl;
11239
11468
  constructor(apiKeyOrConfig) {
11240
11469
  const config = typeof apiKeyOrConfig === "string" ? { apiKey: apiKeyOrConfig } : apiKeyOrConfig;
11470
+ this.region = config.region ?? DEFAULT_TILE_REGION;
11471
+ this.tileBaseUrl = config.tileBaseUrl ?? DEFAULT_TILE_BASE_URL;
11241
11472
  this.http = new HttpClient(config.apiKey, config.baseUrl, config.retries);
11242
- this.tiles = new TilesEndpoint(this.http);
11473
+ const tileOpts = { region: this.region, tileBaseUrl: this.tileBaseUrl };
11474
+ this.tiles = new TilesEndpoint(this.http, tileOpts);
11243
11475
  this.cells = new CellsEndpoint(this.http);
11244
11476
  this.coverage = new CoverageEndpoint(this.http);
11245
11477
  this.featuresEp = new FeaturesEndpoint(this.http);
11246
11478
  this.queryEp = new QueryEndpoint(this.http);
11247
11479
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
11480
+ this.tileAuth = new TileAuth(this.http, tileOpts);
11248
11481
  }
11249
11482
  /* ── Convenience shortcuts ───────────────────────── */
11250
11483
  /** Query GeoJSON features by bounding box */
@@ -11321,28 +11554,63 @@ var Chrt = class {
11321
11554
  );
11322
11555
  }
11323
11556
  /* ── Map integration helpers ─────────────────────── */
11324
- /** Returns a style.json URL ready for Mapbox GL / MapLibre */
11557
+ /**
11558
+ * Build a ready-to-use MapLibre / Mapbox GL style object for CHRT's nautical
11559
+ * charts. The default is the full IHO **S-52** portrayal; pick a palette and
11560
+ * tweak rendering via `opts`. The returned object is a standard MapLibre
11561
+ * style — mutate it freely, or build your own from scratch over the CHRT
11562
+ * vector source (see `buildSources`) for a fully custom look.
11563
+ *
11564
+ * Pair with `transformRequest()` so tile requests carry their auth token.
11565
+ *
11566
+ * @example
11567
+ * const map = new maplibregl.Map({
11568
+ * container: 'map',
11569
+ * style: chrt.style({ palette: 'DAY' }),
11570
+ * transformRequest: chrt.transformRequest(),
11571
+ * });
11572
+ *
11573
+ * @remarks S-101 portrayal is on the roadmap; today `style()` emits S-52.
11574
+ */
11575
+ style(opts = {}) {
11576
+ const { palette = "DAY", ...styleOpts } = opts;
11577
+ return generateStyle(palette, {
11578
+ region: this.region,
11579
+ tileBaseUrl: this.tileBaseUrl,
11580
+ ...styleOpts
11581
+ });
11582
+ }
11583
+ /**
11584
+ * @deprecated CHRT serves no remote `style.json`. Use {@link style} to get a
11585
+ * MapLibre style object instead. Throws to avoid silently pointing a map at a
11586
+ * dead URL.
11587
+ */
11325
11588
  styleUrl() {
11326
11589
  return this.tiles.styleUrl();
11327
11590
  }
11328
11591
  /**
11329
- * Returns a `transformRequest` function for Mapbox GL JS / MapLibre GL JS.
11330
- * Injects the API key header for requests to api.chrt.co.
11592
+ * Returns a `transformRequest` for Mapbox GL JS / MapLibre GL JS that mints a
11593
+ * short-lived tile JWT from your API key and attaches it as
11594
+ * `Authorization: Bearer` on requests to `tiles.chrt.co` only. Style, sprite,
11595
+ * and glyph URLs pass through untouched. The cache warms in the background on
11596
+ * first call, so the first tile or two may be retried by the renderer.
11331
11597
  *
11332
11598
  * @example
11333
- * const map = new mapboxgl.Map({
11334
- * style: chrt.styleUrl(),
11599
+ * const map = new maplibregl.Map({
11600
+ * style: chrt.style(),
11335
11601
  * transformRequest: chrt.transformRequest(),
11336
11602
  * });
11337
11603
  */
11338
- transformRequest() {
11339
- const apiKey = this.http.getApiKey();
11340
- return (url) => {
11341
- if (url.includes("api.chrt.co")) {
11342
- return { url, headers: { "x-api-key": apiKey } };
11343
- }
11344
- return void 0;
11345
- };
11604
+ transformRequest(opts) {
11605
+ return this.tileAuth.transformRequest(opts?.region);
11606
+ }
11607
+ /**
11608
+ * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
11609
+ * the cache automatically — but useful if you want to surface a mint error
11610
+ * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
11611
+ */
11612
+ async warmTiles(region) {
11613
+ await this.tileAuth.getToken(region);
11346
11614
  }
11347
11615
  };
11348
11616
  // Annotate the CommonJS export names for ESM import in node:
@@ -11358,6 +11626,7 @@ var Chrt = class {
11358
11626
  ChrtError,
11359
11627
  DEFAULT_REGION,
11360
11628
  DEFAULT_TILE_BASE_URL,
11629
+ DEFAULT_TILE_REGION,
11361
11630
  LABELED_LAYERS,
11362
11631
  LABEL_BEHAVIOR,
11363
11632
  NAUTICAL_SYMBOLS,
@@ -11375,6 +11644,8 @@ var Chrt = class {
11375
11644
  S52_NIGHT,
11376
11645
  SYMBOL_GROUPS,
11377
11646
  SYMBOL_MAP,
11647
+ TileAuth,
11648
+ TileTokenError,
11378
11649
  applyColorTheme,
11379
11650
  bandFilterExpression,
11380
11651
  buildLabelLayer,