@highlightxyz/sdk 0.2.0 → 0.4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/v1/media.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,KAAK,UAAU,EAGf,WAAW,EACZ,MAAM,oBAAoB,CAAC;AAwB5B;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe;;+BAW3B,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;+BAuB5B,CAAC"}
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/v1/media.ts"],"names":[],"mappings":"AAeA,OAAO,EACL,KAAK,UAAU,EACf,KAAK,oBAAoB,EAIzB,WAAW,EACZ,MAAM,oBAAoB,CAAC;AAE5B,eAAO,MAAM,mCAAmC,MAAM,CAAC;AACvD,eAAO,MAAM,2BAA2B,WAAa,CAAC;AAEtD,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;AAKrF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB;;;6CAkC9B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,OAAO,GAAG,YAAY,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6D+B,CAAC;AAEjE,MAAM,MAAM,iBAAiB,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAElE,eAAO,MAAM,+BAA+B,+CAKK,CAAC;AAclD,eAAO,MAAM,uBAAuB,+CAGnC,CAAC;AAEF,eAAO,MAAM,0BAA0B,uDAItC,CAAC;AAEF,eAAO,MAAM,oBAAoB,oHAOhC,CAAC;AAEF,eAAO,MAAM,4BAA4B,iLAQxC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,wDAmBvC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB;;yCAgBrC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,4BAA4B,EAAE,kBAM1C,CAAC;AAIF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,QAAQ,6KA2BpB,CAAC;AAwBF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe;;;+BAY3B,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;;+BA0B5B,CAAC"}
package/lib/v1/media.js CHANGED
@@ -1,11 +1,241 @@
1
1
  // Hand-written media URL helpers (not generated — do not place under ./gen).
2
2
  //
3
- // The backend resolves each storage location to an absolute `url` (it owns the
4
- // host config + R2 key layout). These helpers do the *selection & composition*
5
- // on top of those resolved per-location URLs: picking a location by role and,
6
- // for bundle directories, addressing the entry file. They never parse a
7
- // `reference` or know anything about deployment hosts.
8
- import { LocationStatus, StorageRole, } from "./gen/types.gen.js";
3
+ // The API returns storage locations as `provider` + `reference`; turning those
4
+ // into a URL is the consumer's job, and this is where it happens. A location's
5
+ // URL is composed from the asset's own identity, so the backend's storage key
6
+ // layout is invisible here and assets written under older layouts resolve
7
+ // identically.
8
+ //
9
+ // These helpers answer "what should the client fetch right now" — Origin-first,
10
+ // with the caller free to layer gateway failover on top. The backend's
11
+ // `artifactUri` answers a different question ("what immutable URI do we bake
12
+ // into an on-chain document") and is deliberately not shared with this path.
13
+ import { sha256 } from "@noble/hashes/sha2";
14
+ import { bytesToHex } from "@noble/hashes/utils";
15
+ import { LocationStatus, MediaKind, StorageProvider, StorageRole, } from "./gen/types.gen.js";
16
+ export const MEDIA_IMAGE_VARIANT_QUERY_PARAMETER = "v";
17
+ export const MEDIA_IMAGE_MAX_INPUT_BYTES = 20_000_000;
18
+ const isReadableStream = (source) => "getReader" in source;
19
+ /**
20
+ * Compute the upload contract's canonical SHA-256 without collecting the
21
+ * complete file in memory. Browser callers pass `File.stream()`; Node callers
22
+ * can pass `createReadStream(path)` directly.
23
+ */
24
+ export const sha256MediaContent = async (source, options) => {
25
+ const hasher = sha256.create();
26
+ let bytesHashed = 0;
27
+ const update = (chunk) => {
28
+ if (options?.signal?.aborted) {
29
+ throw new DOMException("Hashing aborted.", "AbortError");
30
+ }
31
+ hasher.update(chunk);
32
+ bytesHashed += chunk.byteLength;
33
+ options?.onProgress?.(bytesHashed);
34
+ };
35
+ if (isReadableStream(source)) {
36
+ const reader = source.getReader();
37
+ try {
38
+ while (true) {
39
+ const result = await reader.read();
40
+ if (result.done)
41
+ break;
42
+ update(result.value);
43
+ }
44
+ }
45
+ finally {
46
+ reader.releaseLock();
47
+ }
48
+ }
49
+ else {
50
+ for await (const chunk of source)
51
+ update(chunk);
52
+ }
53
+ return `sha256:${bytesToHex(hasher.digest())}`;
54
+ };
55
+ /**
56
+ * Public, versioned image transformation contract shared by the delivery
57
+ * Worker and SDK consumers. Existing query values and dimensions are immutable;
58
+ * a changed transform must use a new query value.
59
+ */
60
+ export const MEDIA_IMAGE_VARIANTS = {
61
+ avatar: {
62
+ queryValue: "avatar-v1",
63
+ width: 256,
64
+ height: 256,
65
+ fit: "cover",
66
+ quality: 75,
67
+ responsive: false,
68
+ },
69
+ thumb: {
70
+ queryValue: "thumb-v1",
71
+ width: 240,
72
+ height: 240,
73
+ fit: "cover",
74
+ quality: 75,
75
+ responsive: false,
76
+ },
77
+ small: {
78
+ queryValue: "small-v1",
79
+ width: 320,
80
+ fit: "scale-down",
81
+ quality: 78,
82
+ responsive: true,
83
+ },
84
+ card: {
85
+ queryValue: "card-v1",
86
+ width: 640,
87
+ height: 640,
88
+ fit: "cover",
89
+ quality: 80,
90
+ responsive: false,
91
+ },
92
+ preview: {
93
+ queryValue: "preview-v1",
94
+ width: 960,
95
+ fit: "scale-down",
96
+ quality: 82,
97
+ responsive: true,
98
+ },
99
+ hero: {
100
+ queryValue: "hero-v1",
101
+ width: 1600,
102
+ fit: "scale-down",
103
+ quality: 85,
104
+ responsive: true,
105
+ },
106
+ og: {
107
+ queryValue: "og-v1",
108
+ width: 1200,
109
+ height: 630,
110
+ fit: "cover",
111
+ quality: 85,
112
+ responsive: false,
113
+ },
114
+ full: {
115
+ queryValue: "full-v1",
116
+ width: 4096,
117
+ fit: "scale-down",
118
+ quality: 90,
119
+ responsive: true,
120
+ },
121
+ };
122
+ export const RESPONSIVE_MEDIA_IMAGE_VARIANTS = [
123
+ "small",
124
+ "preview",
125
+ "hero",
126
+ "full",
127
+ ];
128
+ const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([
129
+ "image/avif",
130
+ "image/gif",
131
+ "image/heic",
132
+ "image/heif",
133
+ "image/jpeg",
134
+ "image/jpg",
135
+ "image/png",
136
+ "image/webp",
137
+ ]);
138
+ const MODERN_ASSET_PATH = /^\/[0-9a-fA-F-]+(?:\/.*)?$/;
139
+ export const isMediaImageContentType = (value) => {
140
+ const contentType = value?.split(";")[0]?.trim().toLowerCase();
141
+ return contentType !== undefined && SUPPORTED_IMAGE_CONTENT_TYPES.has(contentType);
142
+ };
143
+ export const mediaImageVariantFromQuery = (value) => {
144
+ return (Object.values(MEDIA_IMAGE_VARIANTS).find((variant) => variant.queryValue === value) ?? null);
145
+ };
146
+ export const mediaImageVariantUrl = (sourceUrl, variant) => {
147
+ const url = new URL(sourceUrl);
148
+ url.searchParams.set(MEDIA_IMAGE_VARIANT_QUERY_PARAMETER, MEDIA_IMAGE_VARIANTS[variant].queryValue);
149
+ return url.toString();
150
+ };
151
+ export const responsiveMediaImageVariants = (target) => {
152
+ const targetDefinition = MEDIA_IMAGE_VARIANTS[target];
153
+ if (!targetDefinition.responsive)
154
+ return [];
155
+ return RESPONSIVE_MEDIA_IMAGE_VARIANTS.filter((variant) => MEDIA_IMAGE_VARIANTS[variant].width <= targetDefinition.width);
156
+ };
157
+ /**
158
+ * True only for public URLs owned by Highlight's image-capable delivery
159
+ * Worker. The localhost forms mirror the Worker's explicit development
160
+ * namespaces.
161
+ */
162
+ export const isHighlightMediaDeliveryUrl = (value, hosts = DEFAULT_MEDIA_DELIVERY_HOSTS) => {
163
+ let url;
164
+ try {
165
+ url = new URL(value);
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ if (url.protocol !== "https:" && url.protocol !== "http:")
171
+ return false;
172
+ if (value.startsWith(`${hosts.assets}/`)) {
173
+ return MODERN_ASSET_PATH.test(value.slice(hosts.assets.length));
174
+ }
175
+ if (value.startsWith(`${hosts.legacyAssets}/`)) {
176
+ return value.slice(hosts.legacyAssets.length).startsWith("/main/");
177
+ }
178
+ return false;
179
+ };
180
+ /**
181
+ * Client-side preflight for requesting a named image variant. A zero file size
182
+ * is currently the legacy importer's "unknown" sentinel; the delivery Worker
183
+ * still validates the actual R2 Content-Length before invoking Images.
184
+ */
185
+ export const isMediaImageTransformable = (media, source, hosts = DEFAULT_MEDIA_DELIVERY_HOSTS) => {
186
+ const sizeIsEligible = media.fileSize === null ||
187
+ media.fileSize === 0 ||
188
+ media.fileSize <= MEDIA_IMAGE_MAX_INPUT_BYTES;
189
+ return (source.provider === StorageProvider.R2 &&
190
+ media.kind === MediaKind.File &&
191
+ isMediaImageContentType(media.mimeType) &&
192
+ sizeIsEligible &&
193
+ isHighlightMediaDeliveryUrl(source.url, hosts));
194
+ };
195
+ export const DEFAULT_MEDIA_DELIVERY_HOSTS = {
196
+ assets: "https://assets.highlight.xyz",
197
+ metadata: "https://metadata.highlight.xyz",
198
+ legacyAssets: "https://highlight-creator-assets.highlight.xyz",
199
+ arweaveGateway: "https://arweave.net",
200
+ ipfsGateway: "https://ipfs.io",
201
+ };
202
+ const LEGACY_REFERENCE = /^main\//;
203
+ /**
204
+ * The URL to fetch a media location from.
205
+ *
206
+ * R2 URLs are composed from the asset's own identity — a File is `/{mediaId}`,
207
+ * a directory child is `/{parentId}/{path}` — never by parsing the storage key.
208
+ * That is why a stored key can change shape without any URL changing, and why
209
+ * assets written under older key layouts resolve identically.
210
+ *
211
+ * This answers "what should the browser fetch right now". It is deliberately
212
+ * not the backend's `artifactUri`, which answers "what immutable URI do we bake
213
+ * into an on-chain document" — Archive-first and permanent.
214
+ */
215
+ export const mediaUrl = (media, location, hosts = DEFAULT_MEDIA_DELIVERY_HOSTS) => {
216
+ switch (location.provider) {
217
+ case StorageProvider.R2: {
218
+ // Legacy keys are the one case where the key *is* the public path.
219
+ if (LEGACY_REFERENCE.test(location.reference)) {
220
+ return `${hosts.legacyAssets}/${location.reference}`;
221
+ }
222
+ if (media.parentId && media.path) {
223
+ return `${hosts.assets}/${media.parentId}/${media.path}`;
224
+ }
225
+ return media.kind === MediaKind.Directory
226
+ ? `${hosts.assets}/${media.id}/`
227
+ : `${hosts.assets}/${media.id}`;
228
+ }
229
+ case StorageProvider.Arweave:
230
+ return `${hosts.arweaveGateway}/${location.reference}`;
231
+ case StorageProvider.Ipfs:
232
+ return `${hosts.ipfsGateway}/ipfs/${location.reference.replace(/^ipfs:\/\//, "").replace(/^\//, "")}`;
233
+ case StorageProvider.External:
234
+ return location.reference;
235
+ default:
236
+ return null;
237
+ }
238
+ };
9
239
  const DEFAULT_PREFERENCE = [
10
240
  StorageRole.Archive,
11
241
  StorageRole.Origin,
@@ -15,7 +245,7 @@ const DEFAULT_PREFERENCE = [
15
245
  const DEFAULT_BUNDLE_ENTRY = "index.html";
16
246
  const pickLocation = (media, preference) => {
17
247
  for (const role of preference) {
18
- const location = media.locations.find((l) => l.role === role && l.status === LocationStatus.Succeeded && l.url !== null);
248
+ const location = media.locations.find((l) => l.role === role && l.status === LocationStatus.Succeeded);
19
249
  if (location)
20
250
  return location;
21
251
  }
@@ -37,7 +267,8 @@ export const resolveMediaUrl = (media, options) => {
37
267
  ? options.prefer
38
268
  : [options.prefer]
39
269
  : DEFAULT_PREFERENCE;
40
- return pickLocation(media, preference)?.url ?? null;
270
+ const location = pickLocation(media, preference);
271
+ return location ? mediaUrl(media, location, options?.hosts) : null;
41
272
  };
42
273
  /**
43
274
  * Resolve the renderable URL for a generative code bundle (a Directory media
@@ -54,7 +285,10 @@ export const resolveMediaUrl = (media, options) => {
54
285
  * meaningful.
55
286
  */
56
287
  export const resolveRenderUrl = (media, options) => {
57
- const base = resolveMediaUrl(media, { prefer: [StorageRole.Origin, StorageRole.Mirror] });
288
+ const base = resolveMediaUrl(media, {
289
+ prefer: [StorageRole.Origin, StorageRole.Mirror],
290
+ hosts: options?.hosts,
291
+ });
58
292
  if (!base)
59
293
  return null;
60
294
  // Resolve the entry as a child of the directory base. `new URL(entry, base)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@highlightxyz/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official TypeScript SDK for the Highlight API",
5
5
  "keywords": [
6
6
  "ethereum",
@@ -39,6 +39,9 @@
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
+ "dependencies": {
43
+ "@noble/hashes": "^1.8.0"
44
+ },
42
45
  "main": "./lib/index.js",
43
46
  "module": "./lib/index.js",
44
47
  "types": "./lib/index.d.ts"