@nitida/asset-client 0.17.0 → 0.18.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
@@ -1,3 +1,81 @@
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
+ function assertSha(asset, fn) {
71
+ const sha = asset?.sha;
72
+ if (typeof sha === "string" && /^[0-9a-f]{16,64}$/i.test(sha)) return;
73
+ const hint = asset && typeof asset === "object" && "sha256" in asset ? " The value you passed has `sha256` but not `sha` \u2014 that is the shape `upload()` returns. Use `{ sha: result.sha256.slice(0, 16) }`, or fetch the DTO with `assets.get(id)`." : ` Got ${JSON.stringify(sha)}.`;
74
+ throw new Error(
75
+ `${fn}: no usable \`sha\` on the value you passed, so the URL would contain "undefined" and 404 somewhere else.${hint}`
76
+ );
77
+ }
78
+
1
79
  // src/palette.ts
2
80
  function resolveSwatch(palette, ...keys) {
3
81
  if (!palette) return null;
@@ -15,9 +93,9 @@ function pickAmbientBackground(palette) {
15
93
  function getAmbientGradient(palette, opts = {}) {
16
94
  if (!palette) return void 0;
17
95
  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})`;
96
+ const toHex2 = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
97
+ if (!fromHex || !toHex2) return void 0;
98
+ return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex2})`;
21
99
  }
22
100
  function getTextColorForBackground(swatch) {
23
101
  if (!swatch) return "#000000";
@@ -102,102 +180,6 @@ function getPaletteBlurBackground(palette) {
102
180
  return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
103
181
  }
104
182
 
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
183
  // src/transform.ts
202
184
  var TRANSFORM_WIDTHS = [
203
185
  96,
@@ -269,12 +251,24 @@ function extForOptions(opts) {
269
251
  }
270
252
  }
271
253
  function getVideoTransformUrl(asset, opts) {
254
+ assertSha(asset, "getVideoTransformUrl");
255
+ assertPublic(
256
+ asset,
257
+ "getVideoTransformUrl",
258
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
259
+ );
272
260
  const dsl = serializeTransform(opts);
273
261
  if (!dsl) return null;
274
262
  const ext = opts.format === "webm" ? "webm" : "mp4";
275
263
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
276
264
  }
277
265
  function getHlsStreamingUrl(asset, opts = {}) {
266
+ assertSha(asset, "getHlsStreamingUrl");
267
+ assertPublic(
268
+ asset,
269
+ "getHlsStreamingUrl",
270
+ 'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) \u2014 the worker re-signs the playlist children'
271
+ );
278
272
  const merged = { ...opts, format: "hls" };
279
273
  const dsl = serializeTransform(merged);
280
274
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
@@ -286,9 +280,21 @@ function buildTransformUrl(asset, opts) {
286
280
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
287
281
  }
288
282
  function getTransformUrl(asset, opts) {
283
+ assertSha(asset, "getTransformUrl");
284
+ assertPublic(
285
+ asset,
286
+ "getTransformUrl",
287
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
288
+ );
289
289
  return buildTransformUrl(asset, opts);
290
290
  }
291
291
  function getSignedTransformUrl(asset, opts, signingKey) {
292
+ assertSha(asset, "getSignedTransformUrl");
293
+ assertPublic(
294
+ asset,
295
+ "getSignedTransformUrl",
296
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
297
+ );
292
298
  const url = buildTransformUrl(asset, opts);
293
299
  if (!url) return null;
294
300
  return signTransformUrl(url, signingKey);
@@ -319,12 +325,114 @@ async function hmacSha256Hex(key, message) {
319
325
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
320
326
  }
321
327
  function getTransformSrcSet(asset, widths, extraOpts = {}) {
328
+ assertSha(asset, "getTransformSrcSet");
329
+ assertPublic(
330
+ asset,
331
+ "getTransformSrcSet",
332
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width"
333
+ );
322
334
  return widths.map((w) => {
323
335
  const url = buildTransformUrl(asset, { ...extraOpts, width: w });
324
336
  return url ? `${url} ${w}w` : null;
325
337
  }).filter((s) => s != null).join(", ");
326
338
  }
327
339
 
340
+ // src/slots.ts
341
+ var DEFAULT_TTL_MS = 6e4;
342
+ var cache = /* @__PURE__ */ new Map();
343
+ var endpoint = "https://api.nitida.gofuture.space";
344
+ var apiKey = null;
345
+ var tenantCode = null;
346
+ function configureSlotResolver(opts) {
347
+ if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
348
+ if (opts.apiKey !== void 0) apiKey = opts.apiKey;
349
+ if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
350
+ }
351
+ function invalidateSlotCache(slotKey) {
352
+ if (slotKey === void 0) cache.clear();
353
+ else
354
+ for (const k of cache.keys())
355
+ if (k.endsWith(`:${slotKey}`)) cache.delete(k);
356
+ }
357
+ var baseHeaders = () => {
358
+ const h = {};
359
+ if (apiKey) h.Authorization = `Bearer ${apiKey}`;
360
+ if (tenantCode) h["X-Tenant-Code"] = tenantCode;
361
+ return h;
362
+ };
363
+ async function fetchSlot(slotKey) {
364
+ const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
365
+ headers: baseHeaders()
366
+ });
367
+ if (r.status === 404) return null;
368
+ if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
369
+ return await r.json();
370
+ }
371
+ async function fetchSlotsBulk(slotKeys) {
372
+ if (slotKeys.length === 0) return {};
373
+ const r = await fetch(`${endpoint}/slots/resolve`, {
374
+ method: "POST",
375
+ headers: { ...baseHeaders(), "Content-Type": "application/json" },
376
+ body: JSON.stringify({ keys: slotKeys })
377
+ });
378
+ if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
379
+ const body = await r.json();
380
+ return body.resolved;
381
+ }
382
+ async function resolveSlot(slotKey, opts = {}) {
383
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
384
+ const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
385
+ const now = Date.now();
386
+ let dto;
387
+ const hit = cache.get(cacheKey);
388
+ if (hit && now - hit.fetchedAt < ttl) {
389
+ dto = hit.value;
390
+ } else {
391
+ dto = await fetchSlot(slotKey);
392
+ cache.set(cacheKey, { fetchedAt: now, value: dto });
393
+ }
394
+ return materializeResolution(dto, opts.preset);
395
+ }
396
+ async function resolveSlots(slotKeys, opts = {}) {
397
+ if (slotKeys.length === 0) return {};
398
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
399
+ const now = Date.now();
400
+ const missing = [];
401
+ const out = {};
402
+ for (const k of slotKeys) {
403
+ const cacheKey = `${tenantCode ?? "_"}:${k}`;
404
+ const hit = cache.get(cacheKey);
405
+ if (hit && now - hit.fetchedAt < ttl) {
406
+ out[k] = materializeResolution(hit.value, opts.preset);
407
+ } else {
408
+ missing.push(k);
409
+ }
410
+ }
411
+ if (missing.length > 0) {
412
+ const resolved = await fetchSlotsBulk(missing);
413
+ for (const k of missing) {
414
+ const dto = resolved[k] ?? null;
415
+ cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
416
+ out[k] = materializeResolution(dto, opts.preset);
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+ function defaultPresetFor(asset) {
422
+ if (!asset) return "lg";
423
+ return asset.kind === "video" ? "video" : "lg";
424
+ }
425
+ function materializeResolution(dto, overridePreset) {
426
+ if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
427
+ const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
428
+ const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
429
+ return {
430
+ slot: dto,
431
+ preset: finalPreset,
432
+ url: getAssetUrl(dto.asset, finalPreset)
433
+ };
434
+ }
435
+
328
436
  // src/index.ts
329
437
  var PRESET_SHORT = {
330
438
  thumb: "q",
@@ -451,6 +559,25 @@ function originalExtForMime(mime) {
451
559
  return (mime ? ORIGINAL_EXT_BY_MIME[mime] : void 0) ?? PRESET_EXT.original;
452
560
  }
453
561
  function getAssetUrl(asset, preset) {
562
+ assertSha(asset, "getAssetUrl");
563
+ assertPublic(
564
+ asset,
565
+ "getAssetUrl",
566
+ `getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`
567
+ );
568
+ const fallback = transformFallbackFor(asset, preset);
569
+ if (fallback) return fallback;
570
+ return buildPublicAssetUrl(asset, preset);
571
+ }
572
+ function transformFallbackFor(asset, preset) {
573
+ if (typeof asset.presets !== "string") return null;
574
+ if (hasPreset({ presets: asset.presets }, preset)) return null;
575
+ const maxDim = PRESET_MAX_DIM[preset];
576
+ if (maxDim == null) return null;
577
+ return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
578
+ }
579
+ function buildPublicAssetUrl(asset, preset) {
580
+ assertSha(asset, "getPrivateAssetUrl");
454
581
  if (preset === "original") {
455
582
  const stored = asset.variants?.find((v) => v.preset === "original")?.url;
456
583
  if (stored) return stored;
@@ -464,6 +591,25 @@ function getAssetUrl(asset, preset) {
464
591
  }
465
592
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
466
593
  }
594
+ async function getPrivateAssetUrl(asset, preset, signingKey, opts) {
595
+ return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
596
+ }
597
+ async function getPrivateTransformUrl(asset, opts, signingKey, signOpts) {
598
+ const url = buildTransformUrl(asset, opts);
599
+ if (!url) return null;
600
+ const tid = getTenantId();
601
+ if (tid == null) {
602
+ throw new Error(
603
+ "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."
604
+ );
605
+ }
606
+ const u = new URL(url);
607
+ return signAccessUrl(
608
+ `${u.origin}/${tid.toString(36)}${u.pathname}`,
609
+ signingKey,
610
+ signOpts
611
+ );
612
+ }
467
613
  function hasPreset(asset, preset) {
468
614
  if (preset === "mp3") return asset.presets.includes("mp3");
469
615
  return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
@@ -473,6 +619,12 @@ function stripMultiCharTokens(presets) {
473
619
  }
474
620
  var IMAGE_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
475
621
  function getAssetSrcSet(asset) {
622
+ assertSha(asset, "getAssetSrcSet");
623
+ assertPublic(
624
+ asset,
625
+ "getAssetSrcSet",
626
+ "getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset"
627
+ );
476
628
  return IMAGE_PRESETS.filter(
477
629
  (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null
478
630
  ).map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`).join(", ");
@@ -498,10 +650,14 @@ export {
498
650
  PRESET_MAX_DIM,
499
651
  PRESET_SHORT,
500
652
  TRANSFORM_WIDTHS,
653
+ accessMessage,
654
+ assertPublic,
655
+ assertSha,
501
656
  bestTextContrast,
502
657
  computeVariantDimensions,
503
658
  configureSlotResolver,
504
659
  contrastRatio,
660
+ deriveAccessKey,
505
661
  extractAssetSha,
506
662
  getAmbientGradient,
507
663
  getAssetDimensions,
@@ -512,6 +668,8 @@ export {
512
668
  getHlsStreamingUrl,
513
669
  getPaletteBlurBackground,
514
670
  getPaletteCssVars,
671
+ getPrivateAssetUrl,
672
+ getPrivateTransformUrl,
515
673
  getSignedTransformUrl,
516
674
  getTenantId,
517
675
  getTextColorForBackground,
@@ -529,6 +687,7 @@ export {
529
687
  serializeTransform,
530
688
  setCdnBase,
531
689
  setTenantId,
690
+ signAccessUrl,
532
691
  signTransformUrl
533
692
  };
534
693
  //# sourceMappingURL=index.js.map