@nitida/asset-client 0.16.4 → 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/AGENTS.md +54 -0
- package/README.md +31 -3
- package/dist/index.cjs +247 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +305 -87
- package/dist/index.d.ts +305 -87
- package/dist/index.js +241 -99
- package/dist/index.js.map +1 -1
- package/package.json +2 -6
- package/src/access.ts +200 -0
- package/src/index.ts +278 -3
- package/src/transform.ts +39 -5
package/AGENTS.md
CHANGED
|
@@ -8,6 +8,34 @@ version-pinned answer rather than recalling an API from training data.
|
|
|
8
8
|
This package **builds URLs**. It makes no network calls and takes no API key. If the task involves
|
|
9
9
|
uploading, that is `@nitida/sdk` — and its `skills/nitida-sdk/SKILL.md` is the deeper guide.
|
|
10
10
|
|
|
11
|
+
## Where these symbols also live
|
|
12
|
+
|
|
13
|
+
All **53** exports of this package are re-exported by `@nitida/sdk`, and by its
|
|
14
|
+
`/server` and `/web` subpaths alike — a consumer that already installed the SDK
|
|
15
|
+
never needs a second import, from any of the three.
|
|
16
|
+
|
|
17
|
+
That is asserted in CI, not maintained by hand. ⚠️ It was **not** true before
|
|
18
|
+
2026-08-21: `/server` was short 28 of the root's 72 exports and `/web` short 37,
|
|
19
|
+
the palette helpers, the slot helpers, the HLS-ladder helpers and the preset
|
|
20
|
+
constants among them. If you are on an older `@nitida/sdk`, import them from
|
|
21
|
+
the root or from this package.
|
|
22
|
+
|
|
23
|
+
## ⚠️ `RequestablePreset` is not `VariantPreset`
|
|
24
|
+
|
|
25
|
+
Two sets, two types. What a variant can **be** is not what you can **ask for**.
|
|
26
|
+
|
|
27
|
+
| | ask for it | a variant can be it |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `thumb` `sm` `md` `lg` `xl` `original` `poster` `video` `aiproxy` | ✅ | ✅ |
|
|
30
|
+
| `hls` — the ladder, built when a video transcodes | ❌ | ✅ |
|
|
31
|
+
| `mp3` — emitted by itself alongside any audio original | ❌ | ✅ |
|
|
32
|
+
| `probe` — indexed stills, never on the compact `presets` string | ✅ | ❌ |
|
|
33
|
+
|
|
34
|
+
`regenerate(id, { presets: ["hls"] })` and `upload(f, { presets: ["mp3"] })`
|
|
35
|
+
used to compile and answer **HTTP 400**. Three agents hit that in one
|
|
36
|
+
afternoon. They are compile errors now. Checked in CI against the server's own
|
|
37
|
+
schemas, not against a hand-kept list.
|
|
38
|
+
|
|
11
39
|
## The three that are wrong most often
|
|
12
40
|
|
|
13
41
|
1. **Image widths must be on `TRANSFORM_WIDTHS`.** Any other width is **HTTP 400** at the edge.
|
|
@@ -38,6 +66,32 @@ now emits only the 1-char vocabulary, and `hasPreset` strips multi-char tokens b
|
|
|
38
66
|
either half covers you — a hand-rolled `includes` covers neither. **`mp3` is a deliberate multi-char
|
|
39
67
|
token that stays in the vocabulary**, so a naive test also reads its `m` as `md`.
|
|
40
68
|
|
|
69
|
+
|
|
70
|
+
## Private assets — `visibility`
|
|
71
|
+
|
|
72
|
+
Default is `"public"`. Set `private` and **every public door answers 404** —
|
|
73
|
+
stored variants, the raw original, the HLS ladder, and `/t/` (including the
|
|
74
|
+
poster frame of a private video). The bytes come back only through a signed URL
|
|
75
|
+
that expires.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
|
|
79
|
+
// ON YOUR BACKEND, once you decided this viewer may see it:
|
|
80
|
+
await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
|
|
81
|
+
await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
|
|
85
|
+
DTO before you check storage. It is the number-one support question.
|
|
86
|
+
- **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
|
|
87
|
+
`getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
|
|
88
|
+
`getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
|
|
89
|
+
only when the value you pass carries `visibility`. `{ sha }` alone is never
|
|
90
|
+
refused.
|
|
91
|
+
- **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
|
|
92
|
+
- **The signing key is a backend secret** — it mints URLs for every private
|
|
93
|
+
asset the tenant owns.
|
|
94
|
+
|
|
41
95
|
## The endpoint
|
|
42
96
|
|
|
43
97
|
The API this client's URLs belong to is `https://api.nitida.gofuture.space`. An older, longer
|
package/README.md
CHANGED
|
@@ -9,6 +9,17 @@ to **upload**, that is `@nitida/sdk` (which re-exports everything here).
|
|
|
9
9
|
bun add @nitida/asset-client
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
## Where these symbols live
|
|
13
|
+
|
|
14
|
+
Everything documented here is exported by **`@nitida/asset-client`**, and all
|
|
15
|
+
53 exports are re-exported by the root **`@nitida/sdk`** — if you already
|
|
16
|
+
installed the SDK, importing from its root works and needs no second dependency.
|
|
17
|
+
|
|
18
|
+
`@nitida/sdk/server` and `@nitida/sdk/web` carry the same 53 as well, so any of
|
|
19
|
+
the three works. ⚠️ Not so before 2026-08-21 — `/server` was short 28 of the
|
|
20
|
+
root's exports and `/web` short 37. On an older SDK, import these from the root
|
|
21
|
+
or from this package.
|
|
22
|
+
|
|
12
23
|
## Configure once, at module load
|
|
13
24
|
|
|
14
25
|
The builders read **process-global** config, so pin it where your helpers live — every Server and
|
|
@@ -33,7 +44,7 @@ getTransformSrcSet({ sha }, [640, 960, 1280], { format: "webp" });
|
|
|
33
44
|
```
|
|
34
45
|
|
|
35
46
|
⚠️ **Widths must be on the unsigned ladder** `TRANSFORM_WIDTHS`
|
|
36
|
-
(`160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840`). Anything else
|
|
47
|
+
(`96,128,160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840` — 20 widths). Anything else
|
|
37
48
|
is **HTTP 400** at the edge — it is a DoS guard, not a bug. Import the `TransformWidth` type and an
|
|
38
49
|
off-ladder number becomes a compile error instead of a runtime 400.
|
|
39
50
|
|
|
@@ -51,7 +62,10 @@ getAssetUrl({ sha }, "video"); // → https://8ok.uk/<tenantId base36>/v/<sha1
|
|
|
51
62
|
`getAssetUrl` build the path.
|
|
52
63
|
|
|
53
64
|
⚠️ **Never `getVideoTransformUrl` for a stored video.** That builds a `/t/…` transform URL, which
|
|
54
|
-
|
|
65
|
+
does NOT give you the video. On a video sha it transforms the **poster frame**
|
|
66
|
+
and answers `200 image/webp` (`x-transform-source: poster`) — useful, but an
|
|
67
|
+
image. If that video has no poster it answers `410`. Either way you never get
|
|
68
|
+
playable video out of `/t/`; use `getAssetUrl(asset, "video")`.
|
|
55
69
|
|
|
56
70
|
## The `original` variant
|
|
57
71
|
|
|
@@ -66,6 +80,20 @@ getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
|
|
|
66
80
|
those disagree (`image/jpeg` → `jpeg`, but a camera writes `.jpg`) — measured, `-o.jpeg` **404s**
|
|
67
81
|
while `-o.jpg` is 200. If the URL must be right, **verify it with a HEAD** before relying on it.
|
|
68
82
|
|
|
83
|
+
## Asking for a preset vs. a preset existing
|
|
84
|
+
|
|
85
|
+
`VariantPreset` is what a variant can **be**. `RequestablePreset` is what you
|
|
86
|
+
can **ask for**. They overlap in nine values and differ in three:
|
|
87
|
+
|
|
88
|
+
- **`hls`** and **`mp3`** exist but cannot be ordered — the ladder is built when
|
|
89
|
+
a video transcodes, the mp3 is emitted alongside any audio original.
|
|
90
|
+
- **`probe`** can be ordered but never reaches the compact `presets` string, so
|
|
91
|
+
it is not a `VariantPreset`.
|
|
92
|
+
|
|
93
|
+
Sending either of the first two answers **HTTP 400**; the write methods now take
|
|
94
|
+
`RequestablePreset[]`, so it is a compile error instead. A CI check reads the
|
|
95
|
+
server's own schemas and asserts the type still matches them.
|
|
96
|
+
|
|
69
97
|
## Which presets exist
|
|
70
98
|
|
|
71
99
|
Read `dto.presets` — the compact code string (`"oq"` = original + thumb, `"pv"` = poster + video) —
|
|
@@ -94,7 +122,7 @@ shape. `presets` is populated everywhere, on every version — so read existence
|
|
|
94
122
|
| Symptom | Cause |
|
|
95
123
|
|---|---|
|
|
96
124
|
| **400** on an image URL | width not on `TRANSFORM_WIDTHS` |
|
|
97
|
-
| **410** on a video URL |
|
|
125
|
+
| **410** on a video `/t/` URL | that video has no `poster` variant. With one, `/t/` returns the poster as an image — never the video. Use `getAssetUrl(asset, "video")` |
|
|
98
126
|
| **404** on a video URL | decimal tenant prefix instead of base36 |
|
|
99
127
|
| `original` 404s | no `mime` passed, or the stored ext came from the filename (`-o.jpg` vs `-o.jpeg`), or it was never written |
|
|
100
128
|
|
package/dist/index.cjs
CHANGED
|
@@ -25,10 +25,13 @@ __export(index_exports, {
|
|
|
25
25
|
PRESET_MAX_DIM: () => PRESET_MAX_DIM,
|
|
26
26
|
PRESET_SHORT: () => PRESET_SHORT,
|
|
27
27
|
TRANSFORM_WIDTHS: () => TRANSFORM_WIDTHS,
|
|
28
|
+
accessMessage: () => accessMessage,
|
|
29
|
+
assertPublic: () => assertPublic,
|
|
28
30
|
bestTextContrast: () => bestTextContrast,
|
|
29
31
|
computeVariantDimensions: () => computeVariantDimensions,
|
|
30
32
|
configureSlotResolver: () => configureSlotResolver,
|
|
31
33
|
contrastRatio: () => contrastRatio,
|
|
34
|
+
deriveAccessKey: () => deriveAccessKey,
|
|
32
35
|
extractAssetSha: () => extractAssetSha,
|
|
33
36
|
getAmbientGradient: () => getAmbientGradient,
|
|
34
37
|
getAssetDimensions: () => getAssetDimensions,
|
|
@@ -39,6 +42,8 @@ __export(index_exports, {
|
|
|
39
42
|
getHlsStreamingUrl: () => getHlsStreamingUrl,
|
|
40
43
|
getPaletteBlurBackground: () => getPaletteBlurBackground,
|
|
41
44
|
getPaletteCssVars: () => getPaletteCssVars,
|
|
45
|
+
getPrivateAssetUrl: () => getPrivateAssetUrl,
|
|
46
|
+
getPrivateTransformUrl: () => getPrivateTransformUrl,
|
|
42
47
|
getSignedTransformUrl: () => getSignedTransformUrl,
|
|
43
48
|
getTenantId: () => getTenantId,
|
|
44
49
|
getTextColorForBackground: () => getTextColorForBackground,
|
|
@@ -56,10 +61,81 @@ __export(index_exports, {
|
|
|
56
61
|
serializeTransform: () => serializeTransform,
|
|
57
62
|
setCdnBase: () => setCdnBase,
|
|
58
63
|
setTenantId: () => setTenantId,
|
|
64
|
+
signAccessUrl: () => signAccessUrl,
|
|
59
65
|
signTransformUrl: () => signTransformUrl
|
|
60
66
|
});
|
|
61
67
|
module.exports = __toCommonJS(index_exports);
|
|
62
68
|
|
|
69
|
+
// src/access.ts
|
|
70
|
+
var ACCESS_KEY_INFO = "nitida/access/v1";
|
|
71
|
+
async function hmac(key, message) {
|
|
72
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
73
|
+
"raw",
|
|
74
|
+
key,
|
|
75
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
76
|
+
false,
|
|
77
|
+
["sign"]
|
|
78
|
+
);
|
|
79
|
+
return new Uint8Array(
|
|
80
|
+
await crypto.subtle.sign(
|
|
81
|
+
"HMAC",
|
|
82
|
+
cryptoKey,
|
|
83
|
+
new TextEncoder().encode(message)
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
var toHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
88
|
+
async function deriveAccessKey(signingKey) {
|
|
89
|
+
return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
|
|
90
|
+
}
|
|
91
|
+
function accessMessage(tenantPrefix, exp, resourcePath) {
|
|
92
|
+
return `${tenantPrefix}
|
|
93
|
+
${exp}
|
|
94
|
+
${resourcePath.replace(/^\/+/, "")}`;
|
|
95
|
+
}
|
|
96
|
+
async function signAccessUrl(publicUrl, signingKey, opts) {
|
|
97
|
+
if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const u = new URL(publicUrl);
|
|
103
|
+
const segments = u.pathname.split("/").filter(Boolean);
|
|
104
|
+
if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
|
|
105
|
+
segments.shift();
|
|
106
|
+
}
|
|
107
|
+
const tenantPrefix = segments.shift();
|
|
108
|
+
if (!tenantPrefix || segments.length === 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!/^[vrt]$/.test(segments[0])) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`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}.`)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const resourcePath = segments.join("/");
|
|
119
|
+
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
120
|
+
const exp = now + Math.floor(opts.expiresInSeconds);
|
|
121
|
+
const sig = toHex(
|
|
122
|
+
await hmac(
|
|
123
|
+
await deriveAccessKey(signingKey),
|
|
124
|
+
accessMessage(tenantPrefix, exp, resourcePath)
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
|
|
128
|
+
u.searchParams.set("exp", String(exp));
|
|
129
|
+
u.searchParams.set("sig", sig);
|
|
130
|
+
return u.toString();
|
|
131
|
+
}
|
|
132
|
+
function assertPublic(asset, fn, escape) {
|
|
133
|
+
if (asset.visibility !== "private") return;
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${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.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
63
139
|
// src/palette.ts
|
|
64
140
|
function resolveSwatch(palette, ...keys) {
|
|
65
141
|
if (!palette) return null;
|
|
@@ -77,9 +153,9 @@ function pickAmbientBackground(palette) {
|
|
|
77
153
|
function getAmbientGradient(palette, opts = {}) {
|
|
78
154
|
if (!palette) return void 0;
|
|
79
155
|
const fromHex = palette[opts.from ?? "lm"] ?? palette.m ?? palette.d;
|
|
80
|
-
const
|
|
81
|
-
if (!fromHex || !
|
|
82
|
-
return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${
|
|
156
|
+
const toHex2 = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
|
|
157
|
+
if (!fromHex || !toHex2) return void 0;
|
|
158
|
+
return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex2})`;
|
|
83
159
|
}
|
|
84
160
|
function getTextColorForBackground(swatch) {
|
|
85
161
|
if (!swatch) return "#000000";
|
|
@@ -164,102 +240,6 @@ function getPaletteBlurBackground(palette) {
|
|
|
164
240
|
return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
|
|
165
241
|
}
|
|
166
242
|
|
|
167
|
-
// src/slots.ts
|
|
168
|
-
var DEFAULT_TTL_MS = 6e4;
|
|
169
|
-
var cache = /* @__PURE__ */ new Map();
|
|
170
|
-
var endpoint = "https://api.nitida.gofuture.space";
|
|
171
|
-
var apiKey = null;
|
|
172
|
-
var tenantCode = null;
|
|
173
|
-
function configureSlotResolver(opts) {
|
|
174
|
-
if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
|
|
175
|
-
if (opts.apiKey !== void 0) apiKey = opts.apiKey;
|
|
176
|
-
if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
|
|
177
|
-
}
|
|
178
|
-
function invalidateSlotCache(slotKey) {
|
|
179
|
-
if (slotKey === void 0) cache.clear();
|
|
180
|
-
else
|
|
181
|
-
for (const k of cache.keys())
|
|
182
|
-
if (k.endsWith(`:${slotKey}`)) cache.delete(k);
|
|
183
|
-
}
|
|
184
|
-
var baseHeaders = () => {
|
|
185
|
-
const h = {};
|
|
186
|
-
if (apiKey) h.Authorization = `Bearer ${apiKey}`;
|
|
187
|
-
if (tenantCode) h["X-Tenant-Code"] = tenantCode;
|
|
188
|
-
return h;
|
|
189
|
-
};
|
|
190
|
-
async function fetchSlot(slotKey) {
|
|
191
|
-
const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
|
|
192
|
-
headers: baseHeaders()
|
|
193
|
-
});
|
|
194
|
-
if (r.status === 404) return null;
|
|
195
|
-
if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
|
|
196
|
-
return await r.json();
|
|
197
|
-
}
|
|
198
|
-
async function fetchSlotsBulk(slotKeys) {
|
|
199
|
-
if (slotKeys.length === 0) return {};
|
|
200
|
-
const r = await fetch(`${endpoint}/slots/resolve`, {
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
|
203
|
-
body: JSON.stringify({ keys: slotKeys })
|
|
204
|
-
});
|
|
205
|
-
if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
|
|
206
|
-
const body = await r.json();
|
|
207
|
-
return body.resolved;
|
|
208
|
-
}
|
|
209
|
-
async function resolveSlot(slotKey, opts = {}) {
|
|
210
|
-
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
211
|
-
const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
|
|
212
|
-
const now = Date.now();
|
|
213
|
-
let dto;
|
|
214
|
-
const hit = cache.get(cacheKey);
|
|
215
|
-
if (hit && now - hit.fetchedAt < ttl) {
|
|
216
|
-
dto = hit.value;
|
|
217
|
-
} else {
|
|
218
|
-
dto = await fetchSlot(slotKey);
|
|
219
|
-
cache.set(cacheKey, { fetchedAt: now, value: dto });
|
|
220
|
-
}
|
|
221
|
-
return materializeResolution(dto, opts.preset);
|
|
222
|
-
}
|
|
223
|
-
async function resolveSlots(slotKeys, opts = {}) {
|
|
224
|
-
if (slotKeys.length === 0) return {};
|
|
225
|
-
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
const missing = [];
|
|
228
|
-
const out = {};
|
|
229
|
-
for (const k of slotKeys) {
|
|
230
|
-
const cacheKey = `${tenantCode ?? "_"}:${k}`;
|
|
231
|
-
const hit = cache.get(cacheKey);
|
|
232
|
-
if (hit && now - hit.fetchedAt < ttl) {
|
|
233
|
-
out[k] = materializeResolution(hit.value, opts.preset);
|
|
234
|
-
} else {
|
|
235
|
-
missing.push(k);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (missing.length > 0) {
|
|
239
|
-
const resolved = await fetchSlotsBulk(missing);
|
|
240
|
-
for (const k of missing) {
|
|
241
|
-
const dto = resolved[k] ?? null;
|
|
242
|
-
cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
|
|
243
|
-
out[k] = materializeResolution(dto, opts.preset);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return out;
|
|
247
|
-
}
|
|
248
|
-
function defaultPresetFor(asset) {
|
|
249
|
-
if (!asset) return "lg";
|
|
250
|
-
return asset.kind === "video" ? "video" : "lg";
|
|
251
|
-
}
|
|
252
|
-
function materializeResolution(dto, overridePreset) {
|
|
253
|
-
if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
|
|
254
|
-
const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
|
|
255
|
-
const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
|
|
256
|
-
return {
|
|
257
|
-
slot: dto,
|
|
258
|
-
preset: finalPreset,
|
|
259
|
-
url: getAssetUrl(dto.asset, finalPreset)
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
243
|
// src/transform.ts
|
|
264
244
|
var TRANSFORM_WIDTHS = [
|
|
265
245
|
96,
|
|
@@ -331,12 +311,22 @@ function extForOptions(opts) {
|
|
|
331
311
|
}
|
|
332
312
|
}
|
|
333
313
|
function getVideoTransformUrl(asset, opts) {
|
|
314
|
+
assertPublic(
|
|
315
|
+
asset,
|
|
316
|
+
"getVideoTransformUrl",
|
|
317
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
|
|
318
|
+
);
|
|
334
319
|
const dsl = serializeTransform(opts);
|
|
335
320
|
if (!dsl) return null;
|
|
336
321
|
const ext = opts.format === "webm" ? "webm" : "mp4";
|
|
337
322
|
return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
|
|
338
323
|
}
|
|
339
324
|
function getHlsStreamingUrl(asset, opts = {}) {
|
|
325
|
+
assertPublic(
|
|
326
|
+
asset,
|
|
327
|
+
"getHlsStreamingUrl",
|
|
328
|
+
'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) \u2014 the worker re-signs the playlist children'
|
|
329
|
+
);
|
|
340
330
|
const merged = { ...opts, format: "hls" };
|
|
341
331
|
const dsl = serializeTransform(merged);
|
|
342
332
|
return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
|
|
@@ -348,9 +338,19 @@ function buildTransformUrl(asset, opts) {
|
|
|
348
338
|
return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
|
|
349
339
|
}
|
|
350
340
|
function getTransformUrl(asset, opts) {
|
|
341
|
+
assertPublic(
|
|
342
|
+
asset,
|
|
343
|
+
"getTransformUrl",
|
|
344
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
|
|
345
|
+
);
|
|
351
346
|
return buildTransformUrl(asset, opts);
|
|
352
347
|
}
|
|
353
348
|
function getSignedTransformUrl(asset, opts, signingKey) {
|
|
349
|
+
assertPublic(
|
|
350
|
+
asset,
|
|
351
|
+
"getSignedTransformUrl",
|
|
352
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
|
|
353
|
+
);
|
|
354
354
|
const url = buildTransformUrl(asset, opts);
|
|
355
355
|
if (!url) return null;
|
|
356
356
|
return signTransformUrl(url, signingKey);
|
|
@@ -381,12 +381,113 @@ async function hmacSha256Hex(key, message) {
|
|
|
381
381
|
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
382
382
|
}
|
|
383
383
|
function getTransformSrcSet(asset, widths, extraOpts = {}) {
|
|
384
|
+
assertPublic(
|
|
385
|
+
asset,
|
|
386
|
+
"getTransformSrcSet",
|
|
387
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width"
|
|
388
|
+
);
|
|
384
389
|
return widths.map((w) => {
|
|
385
390
|
const url = buildTransformUrl(asset, { ...extraOpts, width: w });
|
|
386
391
|
return url ? `${url} ${w}w` : null;
|
|
387
392
|
}).filter((s) => s != null).join(", ");
|
|
388
393
|
}
|
|
389
394
|
|
|
395
|
+
// src/slots.ts
|
|
396
|
+
var DEFAULT_TTL_MS = 6e4;
|
|
397
|
+
var cache = /* @__PURE__ */ new Map();
|
|
398
|
+
var endpoint = "https://api.nitida.gofuture.space";
|
|
399
|
+
var apiKey = null;
|
|
400
|
+
var tenantCode = null;
|
|
401
|
+
function configureSlotResolver(opts) {
|
|
402
|
+
if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
|
|
403
|
+
if (opts.apiKey !== void 0) apiKey = opts.apiKey;
|
|
404
|
+
if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
|
|
405
|
+
}
|
|
406
|
+
function invalidateSlotCache(slotKey) {
|
|
407
|
+
if (slotKey === void 0) cache.clear();
|
|
408
|
+
else
|
|
409
|
+
for (const k of cache.keys())
|
|
410
|
+
if (k.endsWith(`:${slotKey}`)) cache.delete(k);
|
|
411
|
+
}
|
|
412
|
+
var baseHeaders = () => {
|
|
413
|
+
const h = {};
|
|
414
|
+
if (apiKey) h.Authorization = `Bearer ${apiKey}`;
|
|
415
|
+
if (tenantCode) h["X-Tenant-Code"] = tenantCode;
|
|
416
|
+
return h;
|
|
417
|
+
};
|
|
418
|
+
async function fetchSlot(slotKey) {
|
|
419
|
+
const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
|
|
420
|
+
headers: baseHeaders()
|
|
421
|
+
});
|
|
422
|
+
if (r.status === 404) return null;
|
|
423
|
+
if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
|
|
424
|
+
return await r.json();
|
|
425
|
+
}
|
|
426
|
+
async function fetchSlotsBulk(slotKeys) {
|
|
427
|
+
if (slotKeys.length === 0) return {};
|
|
428
|
+
const r = await fetch(`${endpoint}/slots/resolve`, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers: { ...baseHeaders(), "Content-Type": "application/json" },
|
|
431
|
+
body: JSON.stringify({ keys: slotKeys })
|
|
432
|
+
});
|
|
433
|
+
if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
|
|
434
|
+
const body = await r.json();
|
|
435
|
+
return body.resolved;
|
|
436
|
+
}
|
|
437
|
+
async function resolveSlot(slotKey, opts = {}) {
|
|
438
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
439
|
+
const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
let dto;
|
|
442
|
+
const hit = cache.get(cacheKey);
|
|
443
|
+
if (hit && now - hit.fetchedAt < ttl) {
|
|
444
|
+
dto = hit.value;
|
|
445
|
+
} else {
|
|
446
|
+
dto = await fetchSlot(slotKey);
|
|
447
|
+
cache.set(cacheKey, { fetchedAt: now, value: dto });
|
|
448
|
+
}
|
|
449
|
+
return materializeResolution(dto, opts.preset);
|
|
450
|
+
}
|
|
451
|
+
async function resolveSlots(slotKeys, opts = {}) {
|
|
452
|
+
if (slotKeys.length === 0) return {};
|
|
453
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const missing = [];
|
|
456
|
+
const out = {};
|
|
457
|
+
for (const k of slotKeys) {
|
|
458
|
+
const cacheKey = `${tenantCode ?? "_"}:${k}`;
|
|
459
|
+
const hit = cache.get(cacheKey);
|
|
460
|
+
if (hit && now - hit.fetchedAt < ttl) {
|
|
461
|
+
out[k] = materializeResolution(hit.value, opts.preset);
|
|
462
|
+
} else {
|
|
463
|
+
missing.push(k);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (missing.length > 0) {
|
|
467
|
+
const resolved = await fetchSlotsBulk(missing);
|
|
468
|
+
for (const k of missing) {
|
|
469
|
+
const dto = resolved[k] ?? null;
|
|
470
|
+
cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
|
|
471
|
+
out[k] = materializeResolution(dto, opts.preset);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
function defaultPresetFor(asset) {
|
|
477
|
+
if (!asset) return "lg";
|
|
478
|
+
return asset.kind === "video" ? "video" : "lg";
|
|
479
|
+
}
|
|
480
|
+
function materializeResolution(dto, overridePreset) {
|
|
481
|
+
if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
|
|
482
|
+
const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
|
|
483
|
+
const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
|
|
484
|
+
return {
|
|
485
|
+
slot: dto,
|
|
486
|
+
preset: finalPreset,
|
|
487
|
+
url: getAssetUrl(dto.asset, finalPreset)
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
390
491
|
// src/index.ts
|
|
391
492
|
var PRESET_SHORT = {
|
|
392
493
|
thumb: "q",
|
|
@@ -513,6 +614,23 @@ function originalExtForMime(mime) {
|
|
|
513
614
|
return (mime ? ORIGINAL_EXT_BY_MIME[mime] : void 0) ?? PRESET_EXT.original;
|
|
514
615
|
}
|
|
515
616
|
function getAssetUrl(asset, preset) {
|
|
617
|
+
assertPublic(
|
|
618
|
+
asset,
|
|
619
|
+
"getAssetUrl",
|
|
620
|
+
`getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`
|
|
621
|
+
);
|
|
622
|
+
const fallback = transformFallbackFor(asset, preset);
|
|
623
|
+
if (fallback) return fallback;
|
|
624
|
+
return buildPublicAssetUrl(asset, preset);
|
|
625
|
+
}
|
|
626
|
+
function transformFallbackFor(asset, preset) {
|
|
627
|
+
if (typeof asset.presets !== "string") return null;
|
|
628
|
+
if (hasPreset({ presets: asset.presets }, preset)) return null;
|
|
629
|
+
const maxDim = PRESET_MAX_DIM[preset];
|
|
630
|
+
if (maxDim == null) return null;
|
|
631
|
+
return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
|
|
632
|
+
}
|
|
633
|
+
function buildPublicAssetUrl(asset, preset) {
|
|
516
634
|
if (preset === "original") {
|
|
517
635
|
const stored = asset.variants?.find((v) => v.preset === "original")?.url;
|
|
518
636
|
if (stored) return stored;
|
|
@@ -526,6 +644,25 @@ function getAssetUrl(asset, preset) {
|
|
|
526
644
|
}
|
|
527
645
|
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
|
|
528
646
|
}
|
|
647
|
+
async function getPrivateAssetUrl(asset, preset, signingKey, opts) {
|
|
648
|
+
return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
|
|
649
|
+
}
|
|
650
|
+
async function getPrivateTransformUrl(asset, opts, signingKey, signOpts) {
|
|
651
|
+
const url = buildTransformUrl(asset, opts);
|
|
652
|
+
if (!url) return null;
|
|
653
|
+
const tid = getTenantId();
|
|
654
|
+
if (tid == null) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
"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."
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const u = new URL(url);
|
|
660
|
+
return signAccessUrl(
|
|
661
|
+
`${u.origin}/${tid.toString(36)}${u.pathname}`,
|
|
662
|
+
signingKey,
|
|
663
|
+
signOpts
|
|
664
|
+
);
|
|
665
|
+
}
|
|
529
666
|
function hasPreset(asset, preset) {
|
|
530
667
|
if (preset === "mp3") return asset.presets.includes("mp3");
|
|
531
668
|
return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
|
|
@@ -535,6 +672,11 @@ function stripMultiCharTokens(presets) {
|
|
|
535
672
|
}
|
|
536
673
|
var IMAGE_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
|
|
537
674
|
function getAssetSrcSet(asset) {
|
|
675
|
+
assertPublic(
|
|
676
|
+
asset,
|
|
677
|
+
"getAssetSrcSet",
|
|
678
|
+
"getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset"
|
|
679
|
+
);
|
|
538
680
|
return IMAGE_PRESETS.filter(
|
|
539
681
|
(p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null
|
|
540
682
|
).map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`).join(", ");
|
|
@@ -561,10 +703,13 @@ function getAssetDimensions(asset) {
|
|
|
561
703
|
PRESET_MAX_DIM,
|
|
562
704
|
PRESET_SHORT,
|
|
563
705
|
TRANSFORM_WIDTHS,
|
|
706
|
+
accessMessage,
|
|
707
|
+
assertPublic,
|
|
564
708
|
bestTextContrast,
|
|
565
709
|
computeVariantDimensions,
|
|
566
710
|
configureSlotResolver,
|
|
567
711
|
contrastRatio,
|
|
712
|
+
deriveAccessKey,
|
|
568
713
|
extractAssetSha,
|
|
569
714
|
getAmbientGradient,
|
|
570
715
|
getAssetDimensions,
|
|
@@ -575,6 +720,8 @@ function getAssetDimensions(asset) {
|
|
|
575
720
|
getHlsStreamingUrl,
|
|
576
721
|
getPaletteBlurBackground,
|
|
577
722
|
getPaletteCssVars,
|
|
723
|
+
getPrivateAssetUrl,
|
|
724
|
+
getPrivateTransformUrl,
|
|
578
725
|
getSignedTransformUrl,
|
|
579
726
|
getTenantId,
|
|
580
727
|
getTextColorForBackground,
|
|
@@ -592,6 +739,7 @@ function getAssetDimensions(asset) {
|
|
|
592
739
|
serializeTransform,
|
|
593
740
|
setCdnBase,
|
|
594
741
|
setTenantId,
|
|
742
|
+
signAccessUrl,
|
|
595
743
|
signTransformUrl
|
|
596
744
|
});
|
|
597
745
|
//# sourceMappingURL=index.cjs.map
|