@nitida/asset-client 0.20.3 → 0.21.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/src/index.ts CHANGED
@@ -378,6 +378,15 @@ export function hlsLadderAlignment(rungs: HlsRung[]): {
378
378
  */
379
379
  export type AssetDTO = {
380
380
  id: string;
381
+ /**
382
+ * ⭐ The same value as `id`, under the name the WRITE side uses.
383
+ *
384
+ * `upload()` returns `assetId`; the read endpoints returned only `id`, so
385
+ * what `assets.byHash()` handed back could not be fed to `assets.get()`
386
+ * without renaming a field. Optional here because a DTO produced by an older
387
+ * server will not carry it — read `dto.assetId ?? dto.id`.
388
+ */
389
+ assetId?: string;
381
390
  /** First 16 hex chars of sha256 — used to derive CDN URLs. */
382
391
  sha: string;
383
392
  kind: "image" | "video" | "document" | "audio" | "other";
@@ -611,6 +620,13 @@ type OriginalHints = {
611
620
  oext?: string | null;
612
621
  /** Full variant list — carries the stored URL verbatim. Authoritative. */
613
622
  variants?: AssetVariant[];
623
+ /**
624
+ * What the asset IS. Optional because these builders accept a minimal
625
+ * `{ sha }`, and absent means "we cannot know" — never "assume the worst".
626
+ * Used to refuse `hls` on something that can never have a ladder; see the
627
+ * `hls` branch of `getAssetUrl`.
628
+ */
629
+ kind?: AssetDTO["kind"];
614
630
  };
615
631
 
616
632
  /**
@@ -768,6 +784,33 @@ function buildPublicAssetUrl(
768
784
  // missing. Both are real; neither is a guess.
769
785
  const stored = asset.variants?.find((v) => v.preset === "hls")?.url;
770
786
  if (stored) return stored;
787
+ // No stored ladder. Before falling through to the transform route, refuse
788
+ // the kinds that can never HAVE one.
789
+ //
790
+ // `/t/format=hls/<sha>.m3u8` on an IMAGE does not 404 — measured against
791
+ // production 2026-08-25, it answers **200 with `content-type: image/webp`**
792
+ // (306 kB, cached `immutable` for a year) because the edge's transform
793
+ // planner short-circuits every image to the image pipeline before it ever
794
+ // looks at `format`. So the polite lie this branch was written to avoid
795
+ // comes back through the other door: not a 404, but a WebP wearing an
796
+ // `.m3u8` filename, which is worse — a player cannot even tell it failed.
797
+ //
798
+ // HLS is built when a VIDEO transcodes; audio gets `mp3`, images get the
799
+ // ladder of resized variants. The stored-URL check above runs FIRST on
800
+ // purpose: if a DTO really carries an `hls` variant, the data beats this
801
+ // inference and we hand it over whatever `kind` claims. We only refuse
802
+ // when we would otherwise fabricate a URL we already know is wrong.
803
+ if (asset.kind != null && asset.kind !== "video") {
804
+ throw new Error(
805
+ `${caller}: this asset is \`${asset.kind}\`, and only a video has an ` +
806
+ "HLS ladder. Asking for `hls` here would build " +
807
+ "`/t/format=hls/<sha>.m3u8`, which does NOT 404 — the edge answers " +
808
+ "200 with the image bytes under an .m3u8 name, so a player fails " +
809
+ "with no way to see why. For an image use a size preset " +
810
+ `(\`${caller}(asset, "md")\`) or a transform ` +
811
+ "(`getTransformUrl`); for audio use `mp3`.",
812
+ );
813
+ }
771
814
  return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;
772
815
  }
773
816
  return `${cdnBaseUrl}/${variantPrefix(caller)}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
@@ -878,11 +921,30 @@ export async function getPrivateTransformUrl(
878
921
  * ```
879
922
  */
880
923
  export function hasPreset(
881
- asset: Pick<AssetDTO, "presets">,
924
+ asset: { presets?: string | null },
882
925
  preset: VariantPreset,
883
926
  ): boolean {
884
- if (preset === "mp3") return asset.presets.includes("mp3");
885
- return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
927
+ // ⭐⭐ `presets` PUEDE FALTAR, Y ASUMIR QUE NO ERA UN CRASH EN PRODUCCIÓN
928
+ //
929
+ // El DTO que `assets.byHash(sha)` devuelve no siempre trae `presets` — igual
930
+ // que no traía `sha`. Y `upload()` le pasa ese DTO crudo a `bestPresetForAsset`
931
+ // → `hasPreset` → acá. Con `presets` ausente, `asset.presets.includes(...)`
932
+ // y `stripMultiCharTokens(asset.presets)` reventaban con
933
+ // `undefined is not an object (evaluating 'presets.replace')`.
934
+ //
935
+ // Medido en el canario de neo 2026-08-25: verde con 0.30.2, rojo con 0.32.2,
936
+ // en la rama ORDINARIA del dedup (`existing.status === "ready"`) — o sea toda
937
+ // re-subida de bytes que ya existen, no el caso raro de ayer.
938
+ //
939
+ // ⭐ La invariante que faltaba no era «el objeto lleva `sha`» sino «todo
940
+ // objeto que `upload()` le pasa a un helper satisface la forma que el helper
941
+ // asume». Se arregla ensanchando la forma del helper —`presets` opcional—, no
942
+ // parcheando cada llamador de a uno. Un `presets` ausente significa «no sé qué
943
+ // variantes tiene», que es `false` para cualquier preset: el llamador cae a su
944
+ // default, que es lo correcto.
945
+ const presets = asset.presets ?? "";
946
+ if (preset === "mp3") return presets.includes("mp3");
947
+ return stripMultiCharTokens(presets).includes(PRESET_SHORT[preset]);
886
948
  }
887
949
 
888
950
  /**
@@ -909,8 +971,8 @@ export function hasPreset(
909
971
  * keep sending them, and this is the check every guide points at as the
910
972
  * reliable one. Order matters — strip the longest tokens first.
911
973
  */
912
- function stripMultiCharTokens(presets: string): string {
913
- return presets
974
+ function stripMultiCharTokens(presets: string | null | undefined): string {
975
+ return (presets ?? "")
914
976
  .replace(/transform-[0-9a-f]*/g, "")
915
977
  .replace(/upscale_[a-z0-9_]*/g, "")
916
978
  .replace(/mp3/g, "")