@chrtco/sdk 0.7.7 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -121,20 +121,243 @@ function sleep(ms) {
121
121
  return new Promise((r) => setTimeout(r, ms));
122
122
  }
123
123
 
124
+ // src/tile-auth.ts
125
+ var DEFAULT_TILE_REGION = "NOAA_ENC";
126
+ var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
127
+ var TileTokenError = class extends Error {
128
+ code;
129
+ status;
130
+ constructor(code, message, status) {
131
+ super(message);
132
+ this.name = "TileTokenError";
133
+ this.code = code;
134
+ this.status = status;
135
+ }
136
+ };
137
+ var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
138
+ var REFRESH_MARGIN_SECONDS = 30;
139
+ var STALE_GRACE_SECONDS = 1;
140
+ var MINT_RETRY_DELAYS_MS = [500, 1e3, 2e3];
141
+ function nowSeconds() {
142
+ return Math.floor(Date.now() / 1e3);
143
+ }
144
+ function sleep2(ms) {
145
+ return new Promise((r) => setTimeout(r, ms));
146
+ }
147
+ function mapErrorCode(raw, status) {
148
+ const known = [
149
+ "invalid_api_key",
150
+ "feature_flag_disabled",
151
+ "region_not_licensed",
152
+ "tile_quota_exceeded",
153
+ "region_not_available"
154
+ ];
155
+ if (typeof raw === "string" && known.includes(raw)) {
156
+ return raw;
157
+ }
158
+ if (status === 401) return "invalid_api_key";
159
+ if (status === 403) return "feature_flag_disabled";
160
+ if (status === 429) return "tile_quota_exceeded";
161
+ if (status === 503) return "region_not_available";
162
+ return "unexpected_response";
163
+ }
164
+ var TileAuth = class {
165
+ constructor(http, opts) {
166
+ this.http = http;
167
+ this.defaultRegion = opts?.region ?? DEFAULT_TILE_REGION;
168
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
169
+ }
170
+ cache = /* @__PURE__ */ new Map();
171
+ pending = /* @__PURE__ */ new Map();
172
+ defaultRegion;
173
+ tileBaseUrl;
174
+ lifecycleRegistered = false;
175
+ /** Mint once via the HTTP client (POST `/mint/tile-token`, `x-api-key`). */
176
+ async mintOnce(region) {
177
+ let res;
178
+ try {
179
+ res = await this.http.postJson("/mint/tile-token", { region });
180
+ } catch (err) {
181
+ throw new TileTokenError(
182
+ "network_error",
183
+ err instanceof Error ? err.message : "mint request failed"
184
+ );
185
+ }
186
+ if (!res.ok) {
187
+ const code = mapErrorCode(res.body?.error, res.status);
188
+ const msg = typeof res.body?.error === "string" ? res.body.error : `HTTP ${res.status}`;
189
+ throw new TileTokenError(code, msg, res.status);
190
+ }
191
+ const b = res.body;
192
+ if (!b || typeof b.token !== "string" || typeof b.expires_at !== "number" || typeof b.kid !== "string" || typeof b.build_tag !== "string" || typeof b.region !== "string") {
193
+ throw new TileTokenError(
194
+ "unexpected_response",
195
+ "mint-tile-token returned a malformed body",
196
+ res.status
197
+ );
198
+ }
199
+ return {
200
+ token: b.token,
201
+ expiresAt: b.expires_at,
202
+ kid: b.kid,
203
+ buildTag: b.build_tag,
204
+ region: b.region
205
+ };
206
+ }
207
+ /** Mint with exponential backoff on network errors only. */
208
+ async mintWithRetry(region) {
209
+ let lastErr;
210
+ for (let attempt = 0; attempt <= MINT_RETRY_DELAYS_MS.length; attempt++) {
211
+ try {
212
+ return await this.mintOnce(region);
213
+ } catch (err) {
214
+ lastErr = err;
215
+ if (err instanceof TileTokenError && err.code !== "network_error") throw err;
216
+ if (attempt === MINT_RETRY_DELAYS_MS.length) break;
217
+ await sleep2(MINT_RETRY_DELAYS_MS[attempt]);
218
+ }
219
+ }
220
+ throw lastErr instanceof Error ? lastErr : new TileTokenError("network_error", "mint failed");
221
+ }
222
+ scheduleRefresh(region, expiresAt) {
223
+ const delaySeconds = expiresAt - nowSeconds() - REFRESH_MARGIN_SECONDS;
224
+ if (delaySeconds <= 0) return null;
225
+ const timer = setTimeout(() => {
226
+ void this.refreshInBackground(region);
227
+ }, delaySeconds * 1e3);
228
+ timer.unref?.();
229
+ return timer;
230
+ }
231
+ async refreshInBackground(region) {
232
+ try {
233
+ const response = await this.mintWithRetry(region);
234
+ this.store(region, response);
235
+ } catch {
236
+ this.invalidate(region);
237
+ }
238
+ }
239
+ store(region, response) {
240
+ const existing = this.cache.get(region);
241
+ if (existing?.refreshTimer) clearTimeout(existing.refreshTimer);
242
+ this.cache.set(region, {
243
+ response,
244
+ refreshTimer: this.scheduleRefresh(region, response.expiresAt)
245
+ });
246
+ this.registerLifecycleHandlers();
247
+ }
248
+ /**
249
+ * Fetch (or reuse a cached) tile token for `region`. Concurrent callers for
250
+ * the same region await the same in-flight mint.
251
+ */
252
+ async getToken(region) {
253
+ const r = region ?? this.defaultRegion;
254
+ const cached = this.cache.get(r);
255
+ if (cached && cached.response.expiresAt - nowSeconds() > REFRESH_MARGIN_SECONDS) {
256
+ return cached.response;
257
+ }
258
+ const inflight = this.pending.get(r);
259
+ if (inflight) return inflight;
260
+ const promise = (async () => {
261
+ try {
262
+ const response = await this.mintWithRetry(r);
263
+ this.store(r, response);
264
+ return response;
265
+ } finally {
266
+ this.pending.delete(r);
267
+ }
268
+ })();
269
+ this.pending.set(r, promise);
270
+ return promise;
271
+ }
272
+ /** Synchronous cache peek — null if absent or within the stale grace window. */
273
+ peek(region) {
274
+ const entry = this.cache.get(region ?? this.defaultRegion);
275
+ if (!entry) return null;
276
+ if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return null;
277
+ return entry.response.token;
278
+ }
279
+ /**
280
+ * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
281
+ * `Authorization: Bearer <jwt>` only to CHRT tile URLs; style, sprite, and
282
+ * glyph URLs pass through untouched (never leak the token to third-party
283
+ * CDNs). Kicks off a background mint immediately so the cache warms within
284
+ * one round-trip; the first tile or two may go unauthenticated and be
285
+ * retried by the renderer.
286
+ */
287
+ transformRequest(region) {
288
+ const r = region ?? this.defaultRegion;
289
+ void this.getToken(r).catch(() => void 0);
290
+ return (url) => {
291
+ if (!url.startsWith(this.tileBaseUrl) || !TILE_URL_PATH.test(url)) return void 0;
292
+ const token = this.peek(r);
293
+ if (!token) return { url };
294
+ return { url, headers: { Authorization: `Bearer ${token}` } };
295
+ };
296
+ }
297
+ /** Clear the cached token + timer for a region. */
298
+ invalidate(region) {
299
+ const r = region ?? this.defaultRegion;
300
+ const entry = this.cache.get(r);
301
+ if (entry?.refreshTimer) clearTimeout(entry.refreshTimer);
302
+ this.cache.delete(r);
303
+ }
304
+ /**
305
+ * Browser-only defense against `setTimeout` freezing while a tab is
306
+ * suspended (laptop sleep, mobile background, bfcache restore): proactively
307
+ * re-mint near-expiry entries on wake. Inert under SSR / Node / Deno.
308
+ */
309
+ registerLifecycleHandlers() {
310
+ if (this.lifecycleRegistered) return;
311
+ if (typeof document === "undefined" || typeof window === "undefined") return;
312
+ const onWake = () => {
313
+ if (document.visibilityState !== "visible") return;
314
+ for (const [region, entry] of this.cache.entries()) {
315
+ if (entry.response.expiresAt - nowSeconds() >= REFRESH_MARGIN_SECONDS) continue;
316
+ void this.refreshInBackground(region);
317
+ }
318
+ };
319
+ document.addEventListener("visibilitychange", onWake);
320
+ window.addEventListener("pageshow", (e) => {
321
+ if (e.persisted) onWake();
322
+ });
323
+ this.lifecycleRegistered = true;
324
+ }
325
+ };
326
+
124
327
  // src/endpoints/tiles.ts
125
328
  var TilesEndpoint = class {
126
- constructor(http) {
329
+ constructor(http, opts) {
127
330
  this.http = http;
331
+ this.region = opts?.region ?? DEFAULT_TILE_REGION;
332
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
128
333
  }
129
- /** Full style.json URL with API key as query param */
334
+ region;
335
+ tileBaseUrl;
336
+ /**
337
+ * @deprecated CHRT has no remote `style.json` endpoint — the S-52 style is
338
+ * generated client-side. Use `chrt.style(opts)` to get a ready-to-use
339
+ * MapLibre style object instead. Calling this throws to prevent silently
340
+ * pointing a map at a dead URL.
341
+ */
130
342
  styleUrl() {
131
- return `${this.http.getBaseUrl()}/tiles/style.json?key=${this.http.getApiKey()}`;
343
+ throw new Error(
344
+ "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"
345
+ );
132
346
  }
133
- /** Individual tile URL template */
134
- tileUrl(z, x, y, format = "mvt") {
135
- return `${this.http.getBaseUrl()}/tiles/${z}/${x}/${y}.${format}?key=${this.http.getApiKey()}`;
347
+ /**
348
+ * Vector tile URL template for the configured region, served by the
349
+ * tile-worker at `tiles.chrt.co`. Tiles are JWT-gated — pair this with
350
+ * `chrt.transformRequest()`, which attaches the `Authorization: Bearer`
351
+ * header. Prefer `chrt.style()`, which wires the source for you.
352
+ */
353
+ tileUrl(z, x, y) {
354
+ return `${this.tileBaseUrl}/v1/tiles/${this.region}/${z}/${x}/${y}.mvt`;
136
355
  }
137
- /** List available tileset groups */
356
+ /**
357
+ * @deprecated Tilesets were consolidated to one PMTiles per region (2026-04);
358
+ * the per-group catalogue endpoint is legacy and no longer served. Render via
359
+ * `chrt.style()` instead, which emits a single vector source per region.
360
+ */
138
361
  async groups() {
139
362
  return this.http.request("/tiles/groups");
140
363
  }
@@ -326,7 +549,7 @@ function mapServerError(body, status) {
326
549
  }
327
550
 
328
551
  // src/endpoints/offline-token-manager.ts
329
- var REFRESH_MARGIN_SECONDS = 30;
552
+ var REFRESH_MARGIN_SECONDS2 = 30;
330
553
  function now() {
331
554
  return Math.floor(Date.now() / 1e3);
332
555
  }
@@ -347,7 +570,7 @@ var OfflineTokenManager = class {
347
570
  }
348
571
  const key = this.cacheKey(deviceId, regions);
349
572
  const cached = this.cache.get(key);
350
- if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS) {
573
+ if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS2) {
351
574
  return cached;
352
575
  }
353
576
  const inflight = this.pending.get(key);
@@ -969,47 +1192,6 @@ function dirname(p) {
969
1192
  return p.slice(0, k);
970
1193
  }
971
1194
 
972
- // src/pagination.ts
973
- async function* paginate(fetchPage, getItems, getTotalCount, options) {
974
- const pageSize = options?.pageSize ?? 100;
975
- const signal = options?.signal;
976
- let offset = 0;
977
- while (true) {
978
- if (signal?.aborted) return;
979
- const response = await fetchPage(offset, signal);
980
- const items = getItems(response);
981
- for (const item of items) {
982
- yield item;
983
- }
984
- if (items.length === 0) return;
985
- offset += items.length;
986
- const total = getTotalCount(response);
987
- if (total > 0 && offset >= total) return;
988
- }
989
- }
990
-
991
- // src/webhooks.ts
992
- var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
993
- function parseWebhook(body) {
994
- if (typeof body !== "object" || body === null) {
995
- throw new Error("Invalid webhook payload: expected an object");
996
- }
997
- const obj = body;
998
- if (typeof obj.id !== "string") {
999
- throw new Error("Invalid webhook payload: missing 'id'");
1000
- }
1001
- if (typeof obj.timestamp !== "string") {
1002
- throw new Error("Invalid webhook payload: missing 'timestamp'");
1003
- }
1004
- if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
1005
- throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
1006
- }
1007
- if (typeof obj.data !== "object" || obj.data === null) {
1008
- throw new Error("Invalid webhook payload: missing 'data'");
1009
- }
1010
- return obj;
1011
- }
1012
-
1013
1195
  // src/atlas/s52-colors.ts
1014
1196
  var S52_DAY = {
1015
1197
  // Depth
@@ -9374,7 +9556,7 @@ var BUILD_MINZOOM = 3;
9374
9556
  var BUILD_MAXZOOM = 14;
9375
9557
 
9376
9558
  // src/atlas/source-registry.ts
9377
- var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
9559
+ var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
9378
9560
  var DEFAULT_REGION = "NOAA_ENC";
9379
9561
  function buildSources(opts = {}) {
9380
9562
  const mode = opts.mode ?? "pmtiles";
@@ -9387,7 +9569,7 @@ function buildSources(opts = {}) {
9387
9569
  }
9388
9570
  };
9389
9571
  }
9390
- const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
9572
+ const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
9391
9573
  return {
9392
9574
  [region]: {
9393
9575
  type: "vector",
@@ -11114,6 +11296,47 @@ function getStyleStats(style) {
11114
11296
  };
11115
11297
  }
11116
11298
 
11299
+ // src/pagination.ts
11300
+ async function* paginate(fetchPage, getItems, getTotalCount, options) {
11301
+ const pageSize = options?.pageSize ?? 100;
11302
+ const signal = options?.signal;
11303
+ let offset = 0;
11304
+ while (true) {
11305
+ if (signal?.aborted) return;
11306
+ const response = await fetchPage(offset, signal);
11307
+ const items = getItems(response);
11308
+ for (const item of items) {
11309
+ yield item;
11310
+ }
11311
+ if (items.length === 0) return;
11312
+ offset += items.length;
11313
+ const total = getTotalCount(response);
11314
+ if (total > 0 && offset >= total) return;
11315
+ }
11316
+ }
11317
+
11318
+ // src/webhooks.ts
11319
+ var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
11320
+ function parseWebhook(body) {
11321
+ if (typeof body !== "object" || body === null) {
11322
+ throw new Error("Invalid webhook payload: expected an object");
11323
+ }
11324
+ const obj = body;
11325
+ if (typeof obj.id !== "string") {
11326
+ throw new Error("Invalid webhook payload: missing 'id'");
11327
+ }
11328
+ if (typeof obj.timestamp !== "string") {
11329
+ throw new Error("Invalid webhook payload: missing 'timestamp'");
11330
+ }
11331
+ if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
11332
+ throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
11333
+ }
11334
+ if (typeof obj.data !== "object" || obj.data === null) {
11335
+ throw new Error("Invalid webhook payload: missing 'data'");
11336
+ }
11337
+ return obj;
11338
+ }
11339
+
11117
11340
  // src/index.ts
11118
11341
  var Chrt = class {
11119
11342
  /** Tile endpoints — styleUrl, tileUrl, groups */
@@ -11131,15 +11354,22 @@ var Chrt = class {
11131
11354
  featuresEp;
11132
11355
  queryEp;
11133
11356
  http;
11357
+ tileAuth;
11358
+ region;
11359
+ tileBaseUrl;
11134
11360
  constructor(apiKeyOrConfig) {
11135
11361
  const config = typeof apiKeyOrConfig === "string" ? { apiKey: apiKeyOrConfig } : apiKeyOrConfig;
11362
+ this.region = config.region ?? DEFAULT_TILE_REGION;
11363
+ this.tileBaseUrl = config.tileBaseUrl ?? DEFAULT_TILE_BASE_URL;
11136
11364
  this.http = new HttpClient(config.apiKey, config.baseUrl, config.retries);
11137
- this.tiles = new TilesEndpoint(this.http);
11365
+ const tileOpts = { region: this.region, tileBaseUrl: this.tileBaseUrl };
11366
+ this.tiles = new TilesEndpoint(this.http, tileOpts);
11138
11367
  this.cells = new CellsEndpoint(this.http);
11139
11368
  this.coverage = new CoverageEndpoint(this.http);
11140
11369
  this.featuresEp = new FeaturesEndpoint(this.http);
11141
11370
  this.queryEp = new QueryEndpoint(this.http);
11142
11371
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
11372
+ this.tileAuth = new TileAuth(this.http, tileOpts);
11143
11373
  }
11144
11374
  /* ── Convenience shortcuts ───────────────────────── */
11145
11375
  /** Query GeoJSON features by bounding box */
@@ -11216,28 +11446,63 @@ var Chrt = class {
11216
11446
  );
11217
11447
  }
11218
11448
  /* ── Map integration helpers ─────────────────────── */
11219
- /** Returns a style.json URL ready for Mapbox GL / MapLibre */
11449
+ /**
11450
+ * Build a ready-to-use MapLibre / Mapbox GL style object for CHRT's nautical
11451
+ * charts. The default is the full IHO **S-52** portrayal; pick a palette and
11452
+ * tweak rendering via `opts`. The returned object is a standard MapLibre
11453
+ * style — mutate it freely, or build your own from scratch over the CHRT
11454
+ * vector source (see `buildSources`) for a fully custom look.
11455
+ *
11456
+ * Pair with `transformRequest()` so tile requests carry their auth token.
11457
+ *
11458
+ * @example
11459
+ * const map = new maplibregl.Map({
11460
+ * container: 'map',
11461
+ * style: chrt.style({ palette: 'DAY' }),
11462
+ * transformRequest: chrt.transformRequest(),
11463
+ * });
11464
+ *
11465
+ * @remarks S-101 portrayal is on the roadmap; today `style()` emits S-52.
11466
+ */
11467
+ style(opts = {}) {
11468
+ const { palette = "DAY", ...styleOpts } = opts;
11469
+ return generateStyle(palette, {
11470
+ region: this.region,
11471
+ tileBaseUrl: this.tileBaseUrl,
11472
+ ...styleOpts
11473
+ });
11474
+ }
11475
+ /**
11476
+ * @deprecated CHRT serves no remote `style.json`. Use {@link style} to get a
11477
+ * MapLibre style object instead. Throws to avoid silently pointing a map at a
11478
+ * dead URL.
11479
+ */
11220
11480
  styleUrl() {
11221
11481
  return this.tiles.styleUrl();
11222
11482
  }
11223
11483
  /**
11224
- * Returns a `transformRequest` function for Mapbox GL JS / MapLibre GL JS.
11225
- * Injects the API key header for requests to api.chrt.co.
11484
+ * Returns a `transformRequest` for Mapbox GL JS / MapLibre GL JS that mints a
11485
+ * short-lived tile JWT from your API key and attaches it as
11486
+ * `Authorization: Bearer` on requests to `tiles.chrt.co` only. Style, sprite,
11487
+ * and glyph URLs pass through untouched. The cache warms in the background on
11488
+ * first call, so the first tile or two may be retried by the renderer.
11226
11489
  *
11227
11490
  * @example
11228
- * const map = new mapboxgl.Map({
11229
- * style: chrt.styleUrl(),
11491
+ * const map = new maplibregl.Map({
11492
+ * style: chrt.style(),
11230
11493
  * transformRequest: chrt.transformRequest(),
11231
11494
  * });
11232
11495
  */
11233
- transformRequest() {
11234
- const apiKey = this.http.getApiKey();
11235
- return (url) => {
11236
- if (url.includes("api.chrt.co")) {
11237
- return { url, headers: { "x-api-key": apiKey } };
11238
- }
11239
- return void 0;
11240
- };
11496
+ transformRequest(opts) {
11497
+ return this.tileAuth.transformRequest(opts?.region);
11498
+ }
11499
+ /**
11500
+ * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
11501
+ * the cache automatically — but useful if you want to surface a mint error
11502
+ * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
11503
+ */
11504
+ async warmTiles(region) {
11505
+ await this.tileAuth.getToken(region);
11241
11506
  }
11242
11507
  };
11243
11508
  export {
@@ -11252,6 +11517,7 @@ export {
11252
11517
  ChrtError,
11253
11518
  DEFAULT_REGION,
11254
11519
  DEFAULT_TILE_BASE_URL,
11520
+ DEFAULT_TILE_REGION,
11255
11521
  LABELED_LAYERS,
11256
11522
  LABEL_BEHAVIOR,
11257
11523
  NAUTICAL_SYMBOLS,
@@ -11269,6 +11535,8 @@ export {
11269
11535
  S52_NIGHT,
11270
11536
  SYMBOL_GROUPS,
11271
11537
  SYMBOL_MAP,
11538
+ TileAuth,
11539
+ TileTokenError,
11272
11540
  applyColorTheme,
11273
11541
  bandFilterExpression,
11274
11542
  buildLabelLayer,