@nitida/asset-client 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,73 @@
1
+ // src/access.ts
2
+ var ACCESS_KEY_INFO = "nitida/access/v1";
3
+ async function hmac(key, message) {
4
+ const cryptoKey = await crypto.subtle.importKey(
5
+ "raw",
6
+ key,
7
+ { name: "HMAC", hash: "SHA-256" },
8
+ false,
9
+ ["sign"]
10
+ );
11
+ return new Uint8Array(
12
+ await crypto.subtle.sign(
13
+ "HMAC",
14
+ cryptoKey,
15
+ new TextEncoder().encode(message)
16
+ )
17
+ );
18
+ }
19
+ var toHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
20
+ async function deriveAccessKey(signingKey) {
21
+ return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
22
+ }
23
+ function accessMessage(tenantPrefix, exp, resourcePath) {
24
+ return `${tenantPrefix}
25
+ ${exp}
26
+ ${resourcePath.replace(/^\/+/, "")}`;
27
+ }
28
+ async function signAccessUrl(publicUrl, signingKey, opts) {
29
+ if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
30
+ throw new Error(
31
+ "signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
32
+ );
33
+ }
34
+ const u = new URL(publicUrl);
35
+ const segments = u.pathname.split("/").filter(Boolean);
36
+ if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
37
+ segments.shift();
38
+ }
39
+ const tenantPrefix = segments.shift();
40
+ if (!tenantPrefix || segments.length === 0) {
41
+ throw new Error(
42
+ `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`
43
+ );
44
+ }
45
+ if (!/^[vrt]$/.test(segments[0])) {
46
+ throw new Error(
47
+ `signAccessUrl: expected /<tenant>/<v|r|t>/\u2026 but the segment after the tenant is "${segments[0]}". ` + (segments[0]?.includes("=") ? `That looks like a transform DSL, so the path is probably /t/<dsl>/<sha>.<ext> \u2014 which has no tenant in it (\`t\` here was read as tenant ${Number.parseInt(tenantPrefix, 36)}). Use getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds }) instead.` : `Got ${u.pathname}.`)
48
+ );
49
+ }
50
+ const resourcePath = segments.join("/");
51
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
52
+ const exp = now + Math.floor(opts.expiresInSeconds);
53
+ const sig = toHex(
54
+ await hmac(
55
+ await deriveAccessKey(signingKey),
56
+ accessMessage(tenantPrefix, exp, resourcePath)
57
+ )
58
+ );
59
+ u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
60
+ u.searchParams.set("exp", String(exp));
61
+ u.searchParams.set("sig", sig);
62
+ return u.toString();
63
+ }
64
+ function assertPublic(asset, fn, escape) {
65
+ if (asset.visibility !== "private") return;
66
+ throw new Error(
67
+ `${fn}: this asset is private, so a public CDN URL for it will answer 404 \u2014 that is the feature, not a missing file. Mint a signed URL on your BACKEND instead: await ${escape}. Never ship the signing key to a browser.`
68
+ );
69
+ }
70
+
1
71
  // src/palette.ts
2
72
  function resolveSwatch(palette, ...keys) {
3
73
  if (!palette) return null;
@@ -15,9 +85,9 @@ function pickAmbientBackground(palette) {
15
85
  function getAmbientGradient(palette, opts = {}) {
16
86
  if (!palette) return void 0;
17
87
  const fromHex = palette[opts.from ?? "lm"] ?? palette.m ?? palette.d;
18
- const toHex = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
19
- if (!fromHex || !toHex) return void 0;
20
- return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex})`;
88
+ const toHex2 = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
89
+ if (!fromHex || !toHex2) return void 0;
90
+ return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex2})`;
21
91
  }
22
92
  function getTextColorForBackground(swatch) {
23
93
  if (!swatch) return "#000000";
@@ -102,102 +172,6 @@ function getPaletteBlurBackground(palette) {
102
172
  return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
103
173
  }
104
174
 
105
- // src/slots.ts
106
- var DEFAULT_TTL_MS = 6e4;
107
- var cache = /* @__PURE__ */ new Map();
108
- var endpoint = "https://api.nitida.gofuture.space";
109
- var apiKey = null;
110
- var tenantCode = null;
111
- function configureSlotResolver(opts) {
112
- if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
113
- if (opts.apiKey !== void 0) apiKey = opts.apiKey;
114
- if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
115
- }
116
- function invalidateSlotCache(slotKey) {
117
- if (slotKey === void 0) cache.clear();
118
- else
119
- for (const k of cache.keys())
120
- if (k.endsWith(`:${slotKey}`)) cache.delete(k);
121
- }
122
- var baseHeaders = () => {
123
- const h = {};
124
- if (apiKey) h.Authorization = `Bearer ${apiKey}`;
125
- if (tenantCode) h["X-Tenant-Code"] = tenantCode;
126
- return h;
127
- };
128
- async function fetchSlot(slotKey) {
129
- const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
130
- headers: baseHeaders()
131
- });
132
- if (r.status === 404) return null;
133
- if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
134
- return await r.json();
135
- }
136
- async function fetchSlotsBulk(slotKeys) {
137
- if (slotKeys.length === 0) return {};
138
- const r = await fetch(`${endpoint}/slots/resolve`, {
139
- method: "POST",
140
- headers: { ...baseHeaders(), "Content-Type": "application/json" },
141
- body: JSON.stringify({ keys: slotKeys })
142
- });
143
- if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
144
- const body = await r.json();
145
- return body.resolved;
146
- }
147
- async function resolveSlot(slotKey, opts = {}) {
148
- const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
149
- const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
150
- const now = Date.now();
151
- let dto;
152
- const hit = cache.get(cacheKey);
153
- if (hit && now - hit.fetchedAt < ttl) {
154
- dto = hit.value;
155
- } else {
156
- dto = await fetchSlot(slotKey);
157
- cache.set(cacheKey, { fetchedAt: now, value: dto });
158
- }
159
- return materializeResolution(dto, opts.preset);
160
- }
161
- async function resolveSlots(slotKeys, opts = {}) {
162
- if (slotKeys.length === 0) return {};
163
- const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
164
- const now = Date.now();
165
- const missing = [];
166
- const out = {};
167
- for (const k of slotKeys) {
168
- const cacheKey = `${tenantCode ?? "_"}:${k}`;
169
- const hit = cache.get(cacheKey);
170
- if (hit && now - hit.fetchedAt < ttl) {
171
- out[k] = materializeResolution(hit.value, opts.preset);
172
- } else {
173
- missing.push(k);
174
- }
175
- }
176
- if (missing.length > 0) {
177
- const resolved = await fetchSlotsBulk(missing);
178
- for (const k of missing) {
179
- const dto = resolved[k] ?? null;
180
- cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
181
- out[k] = materializeResolution(dto, opts.preset);
182
- }
183
- }
184
- return out;
185
- }
186
- function defaultPresetFor(asset) {
187
- if (!asset) return "lg";
188
- return asset.kind === "video" ? "video" : "lg";
189
- }
190
- function materializeResolution(dto, overridePreset) {
191
- if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
192
- const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
193
- const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
194
- return {
195
- slot: dto,
196
- preset: finalPreset,
197
- url: getAssetUrl(dto.asset, finalPreset)
198
- };
199
- }
200
-
201
175
  // src/transform.ts
202
176
  var TRANSFORM_WIDTHS = [
203
177
  96,
@@ -269,12 +243,22 @@ function extForOptions(opts) {
269
243
  }
270
244
  }
271
245
  function getVideoTransformUrl(asset, opts) {
246
+ assertPublic(
247
+ asset,
248
+ "getVideoTransformUrl",
249
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
250
+ );
272
251
  const dsl = serializeTransform(opts);
273
252
  if (!dsl) return null;
274
253
  const ext = opts.format === "webm" ? "webm" : "mp4";
275
254
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
276
255
  }
277
256
  function getHlsStreamingUrl(asset, opts = {}) {
257
+ assertPublic(
258
+ asset,
259
+ "getHlsStreamingUrl",
260
+ 'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) \u2014 the worker re-signs the playlist children'
261
+ );
278
262
  const merged = { ...opts, format: "hls" };
279
263
  const dsl = serializeTransform(merged);
280
264
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
@@ -286,9 +270,19 @@ function buildTransformUrl(asset, opts) {
286
270
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
287
271
  }
288
272
  function getTransformUrl(asset, opts) {
273
+ assertPublic(
274
+ asset,
275
+ "getTransformUrl",
276
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
277
+ );
289
278
  return buildTransformUrl(asset, opts);
290
279
  }
291
280
  function getSignedTransformUrl(asset, opts, signingKey) {
281
+ assertPublic(
282
+ asset,
283
+ "getSignedTransformUrl",
284
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
285
+ );
292
286
  const url = buildTransformUrl(asset, opts);
293
287
  if (!url) return null;
294
288
  return signTransformUrl(url, signingKey);
@@ -319,12 +313,113 @@ async function hmacSha256Hex(key, message) {
319
313
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
320
314
  }
321
315
  function getTransformSrcSet(asset, widths, extraOpts = {}) {
316
+ assertPublic(
317
+ asset,
318
+ "getTransformSrcSet",
319
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width"
320
+ );
322
321
  return widths.map((w) => {
323
322
  const url = buildTransformUrl(asset, { ...extraOpts, width: w });
324
323
  return url ? `${url} ${w}w` : null;
325
324
  }).filter((s) => s != null).join(", ");
326
325
  }
327
326
 
327
+ // src/slots.ts
328
+ var DEFAULT_TTL_MS = 6e4;
329
+ var cache = /* @__PURE__ */ new Map();
330
+ var endpoint = "https://api.nitida.gofuture.space";
331
+ var apiKey = null;
332
+ var tenantCode = null;
333
+ function configureSlotResolver(opts) {
334
+ if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
335
+ if (opts.apiKey !== void 0) apiKey = opts.apiKey;
336
+ if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
337
+ }
338
+ function invalidateSlotCache(slotKey) {
339
+ if (slotKey === void 0) cache.clear();
340
+ else
341
+ for (const k of cache.keys())
342
+ if (k.endsWith(`:${slotKey}`)) cache.delete(k);
343
+ }
344
+ var baseHeaders = () => {
345
+ const h = {};
346
+ if (apiKey) h.Authorization = `Bearer ${apiKey}`;
347
+ if (tenantCode) h["X-Tenant-Code"] = tenantCode;
348
+ return h;
349
+ };
350
+ async function fetchSlot(slotKey) {
351
+ const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
352
+ headers: baseHeaders()
353
+ });
354
+ if (r.status === 404) return null;
355
+ if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
356
+ return await r.json();
357
+ }
358
+ async function fetchSlotsBulk(slotKeys) {
359
+ if (slotKeys.length === 0) return {};
360
+ const r = await fetch(`${endpoint}/slots/resolve`, {
361
+ method: "POST",
362
+ headers: { ...baseHeaders(), "Content-Type": "application/json" },
363
+ body: JSON.stringify({ keys: slotKeys })
364
+ });
365
+ if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
366
+ const body = await r.json();
367
+ return body.resolved;
368
+ }
369
+ async function resolveSlot(slotKey, opts = {}) {
370
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
371
+ const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
372
+ const now = Date.now();
373
+ let dto;
374
+ const hit = cache.get(cacheKey);
375
+ if (hit && now - hit.fetchedAt < ttl) {
376
+ dto = hit.value;
377
+ } else {
378
+ dto = await fetchSlot(slotKey);
379
+ cache.set(cacheKey, { fetchedAt: now, value: dto });
380
+ }
381
+ return materializeResolution(dto, opts.preset);
382
+ }
383
+ async function resolveSlots(slotKeys, opts = {}) {
384
+ if (slotKeys.length === 0) return {};
385
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
386
+ const now = Date.now();
387
+ const missing = [];
388
+ const out = {};
389
+ for (const k of slotKeys) {
390
+ const cacheKey = `${tenantCode ?? "_"}:${k}`;
391
+ const hit = cache.get(cacheKey);
392
+ if (hit && now - hit.fetchedAt < ttl) {
393
+ out[k] = materializeResolution(hit.value, opts.preset);
394
+ } else {
395
+ missing.push(k);
396
+ }
397
+ }
398
+ if (missing.length > 0) {
399
+ const resolved = await fetchSlotsBulk(missing);
400
+ for (const k of missing) {
401
+ const dto = resolved[k] ?? null;
402
+ cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
403
+ out[k] = materializeResolution(dto, opts.preset);
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+ function defaultPresetFor(asset) {
409
+ if (!asset) return "lg";
410
+ return asset.kind === "video" ? "video" : "lg";
411
+ }
412
+ function materializeResolution(dto, overridePreset) {
413
+ if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
414
+ const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
415
+ const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
416
+ return {
417
+ slot: dto,
418
+ preset: finalPreset,
419
+ url: getAssetUrl(dto.asset, finalPreset)
420
+ };
421
+ }
422
+
328
423
  // src/index.ts
329
424
  var PRESET_SHORT = {
330
425
  thumb: "q",
@@ -451,6 +546,23 @@ function originalExtForMime(mime) {
451
546
  return (mime ? ORIGINAL_EXT_BY_MIME[mime] : void 0) ?? PRESET_EXT.original;
452
547
  }
453
548
  function getAssetUrl(asset, preset) {
549
+ assertPublic(
550
+ asset,
551
+ "getAssetUrl",
552
+ `getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`
553
+ );
554
+ const fallback = transformFallbackFor(asset, preset);
555
+ if (fallback) return fallback;
556
+ return buildPublicAssetUrl(asset, preset);
557
+ }
558
+ function transformFallbackFor(asset, preset) {
559
+ if (typeof asset.presets !== "string") return null;
560
+ if (hasPreset({ presets: asset.presets }, preset)) return null;
561
+ const maxDim = PRESET_MAX_DIM[preset];
562
+ if (maxDim == null) return null;
563
+ return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
564
+ }
565
+ function buildPublicAssetUrl(asset, preset) {
454
566
  if (preset === "original") {
455
567
  const stored = asset.variants?.find((v) => v.preset === "original")?.url;
456
568
  if (stored) return stored;
@@ -464,6 +576,25 @@ function getAssetUrl(asset, preset) {
464
576
  }
465
577
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
466
578
  }
579
+ async function getPrivateAssetUrl(asset, preset, signingKey, opts) {
580
+ return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
581
+ }
582
+ async function getPrivateTransformUrl(asset, opts, signingKey, signOpts) {
583
+ const url = buildTransformUrl(asset, opts);
584
+ if (!url) return null;
585
+ const tid = getTenantId();
586
+ if (tid == null) {
587
+ throw new Error(
588
+ "getPrivateTransformUrl: no tenant is configured. Call setTenantId(id) (or construct a NitidaClient with `tenantId`) \u2014 the tenant is part of what the signature covers, so this cannot be guessed."
589
+ );
590
+ }
591
+ const u = new URL(url);
592
+ return signAccessUrl(
593
+ `${u.origin}/${tid.toString(36)}${u.pathname}`,
594
+ signingKey,
595
+ signOpts
596
+ );
597
+ }
467
598
  function hasPreset(asset, preset) {
468
599
  if (preset === "mp3") return asset.presets.includes("mp3");
469
600
  return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
@@ -473,6 +604,11 @@ function stripMultiCharTokens(presets) {
473
604
  }
474
605
  var IMAGE_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
475
606
  function getAssetSrcSet(asset) {
607
+ assertPublic(
608
+ asset,
609
+ "getAssetSrcSet",
610
+ "getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset"
611
+ );
476
612
  return IMAGE_PRESETS.filter(
477
613
  (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null
478
614
  ).map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`).join(", ");
@@ -498,10 +634,13 @@ export {
498
634
  PRESET_MAX_DIM,
499
635
  PRESET_SHORT,
500
636
  TRANSFORM_WIDTHS,
637
+ accessMessage,
638
+ assertPublic,
501
639
  bestTextContrast,
502
640
  computeVariantDimensions,
503
641
  configureSlotResolver,
504
642
  contrastRatio,
643
+ deriveAccessKey,
505
644
  extractAssetSha,
506
645
  getAmbientGradient,
507
646
  getAssetDimensions,
@@ -512,6 +651,8 @@ export {
512
651
  getHlsStreamingUrl,
513
652
  getPaletteBlurBackground,
514
653
  getPaletteCssVars,
654
+ getPrivateAssetUrl,
655
+ getPrivateTransformUrl,
515
656
  getSignedTransformUrl,
516
657
  getTenantId,
517
658
  getTextColorForBackground,
@@ -529,6 +670,7 @@ export {
529
670
  serializeTransform,
530
671
  setCdnBase,
531
672
  setTenantId,
673
+ signAccessUrl,
532
674
  signTransformUrl
533
675
  };
534
676
  //# sourceMappingURL=index.js.map