/** * @aquienpz/sdk — universal client for the aquienpz multi-tenant asset * platform. * * One ergonomic facade over the underlying packages * (`@aquienpz/asset-client` URL builders + `@aquienpz/asset-uploader-web` * + the slot resolver). Auth is a Better Auth API key (`amk_rt_*`) * issued by aquienpz `bootstrap-project.ts`; tenant scope comes from * the key's metadata (`X-Tenant-Code` is log-only). * * Usage: * * import { AquienpzClient } from "@aquienpz/sdk"; * * const aq = new AquienpzClient({ * endpoint: "https://aquienpz-asset-manager-xxx.run.app", * apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!, * tenantCode: "realtyone-cr", * cdnBase: "https://8ok.uk", // optional override * tenantId: 4, // required for tenant-prefixed URLs * }); * * // Slot system (recommended — admin can rebind without redeploys). * const hero = await aq.slots.resolve("storefront.cr.hero"); * const set = await aq.slots.resolveMany(["a", "b", "c"]); * * // Lower-level asset operations. * const asset = await aq.assets.byHash(sha); * const list = await aq.assets.list({ limit: 50 }); * * // Upload bytes / files. * const up = await aq.upload(file, { fileName: "cover.jpg" }); * * For React, see `@aquienpz/sdk/react` (useSlot, useSlots, useAsset). */ import { type AssetDTO, type AssetVariant, configureSlotResolver, getAssetSrcSet, getAssetUrl, getHlsStreamingUrl, getSignedTransformUrl, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, invalidateSlotCache, type ResolveSlotOptions, resolveSlot, resolveSlots, type SignedTransformOptions, type SlotDTO, type SlotResolution, setCdnBase, setTenantId, type TransformOptions, type VariantPreset, } from "@aquienpz/asset-client"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- /** * Permissive constructor options for the root `AquienpzClient`. * * App code should NOT import this type directly — prefer the strict * variants from the subpaths: * * - `WebClientOptions` from `@aquienpz/sdk/web` (no `apiKey`) * - `ServerClientOptions` from `@aquienpz/sdk/server` (`apiKey` required) * * This root type is the union both modes resolve to; the underlying class * accepts both shapes so subpath wrappers can extend without duplication. */ export type AquienpzClientOptions = { /** * Base URL of the aquienpz asset-manager (Cloud Run service URL). * * May be relative (e.g. `/api/am`) ONLY in browser contexts where the * SDK resolves it against `window.location.origin`. Node/Bun consumers * must always pass an absolute URL. */ endpoint: string; /** * Better Auth API key with the `amk_rt_*` prefix. * * **Server-only.** Omit when constructing from `@aquienpz/sdk/web` — * your BFF / route handler injects the bearer header in proxy mode. */ apiKey?: string; /** * Extra headers merged into every request. The documented way for * mobile/Expo clients to authenticate a BFF that gates on the Better Auth * session: they can't send cookies automatically, so they pass * `{ Cookie: authClient.getCookie() }` here (see Better Auth Expo docs, * "Making Authenticated Requests to Your Server"). Web/server consumers omit * this — browsers attach the same-origin cookie and servers pass `apiKey`. */ headers?: Record; /** Tenant code — sent as `X-Tenant-Code` (log-only). Authoritative scope is the key's metadata.tenantId. */ tenantCode: string; /** Numeric tenant id — used to build tenant-prefixed CDN URLs `//v/-.`. */ tenantId: number; /** Override the public CDN base. Defaults to `https://8ok.uk`. */ cdnBase?: string; /** * Tenant's HMAC signing key for transform URLs (Phase 3). Required * only when calling `aq.transform(asset, opts, { sign: true })`. * * Generated server-side per tenant (see `infra/sql/tenants_signed_transforms.sql`); * fetch via `GET /admin/tenants/:id` with an admin key. **Keep it * server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign * URLs from a BFF route handler, or pre-sign at build time. */ signingKey?: string; }; // --------------------------------------------------------------------------- // URL construction // --------------------------------------------------------------------------- /** * Build a fully-qualified URL for an aquienpz endpoint path. * * Accepts both absolute endpoints (`https://aquienpz...run.app`) and * relative ones (`/api/am`) — the latter only works in browser contexts * (resolved against `window.location.origin`). Node/Bun throws a clear * error if a relative endpoint is configured. * * The native `URL` constructor throws on relative inputs, so every fetch * site in the SDK must go through this helper instead of `new URL(...)`. */ function endpointUrl( opts: Pick, path: string, searchParams?: Record, ): URL { const endpoint = opts.endpoint.replace(/\/+$/, ""); const isAbsolute = /^https?:\/\//i.test(endpoint); let base: string; if (isAbsolute) { base = `${endpoint}${path}`; } else if (typeof window !== "undefined" && window.location?.origin) { base = `${window.location.origin}${endpoint}${path}`; } else { throw new Error( `[@aquienpz/sdk] relative endpoint "${endpoint}" requires a browser; ` + "pass an absolute URL when using the SDK from Node/Bun.", ); } const u = new URL(base); if (searchParams) { for (const [k, v] of Object.entries(searchParams)) { if (v !== undefined && v !== null && v !== "") { u.searchParams.set(k, String(v)); } } } return u; } /** Build a request URL as a plain string (no search params). */ function endpointHref( opts: Pick, path: string, ): string { return endpointUrl(opts, path).toString(); } /** * Auth headers — conditionally includes `Authorization` only when an * `apiKey` is present. In BFF-proxy mode (browser via `/web`) the * proxy injects the real bearer header, so we omit it here. */ function authHeaders(opts: AquienpzClientOptions): Record { const h: Record = { // Caller-supplied headers first; `X-Tenant-Code` stays authoritative below. ...opts.headers, "X-Tenant-Code": opts.tenantCode, }; if (opts.apiKey) h.Authorization = `Bearer ${opts.apiKey}`; return h; } // --------------------------------------------------------------------------- // Re-exports (so consumers don't double-import from asset-client) // --------------------------------------------------------------------------- export type { AssetDTO, AssetVariant, ResolveSlotOptions, SignedTransformOptions, SlotDTO, SlotResolution, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, VariantPreset, } from "@aquienpz/asset-client"; export { computeVariantDimensions, extractAssetSha, getAssetDimensions, getAssetSrcSet, getAssetUrl, getHlsStreamingUrl, getSignedTransformUrl, getTenantId, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, serializeTransform, setTenantId, signTransformUrl, } from "@aquienpz/asset-client"; // --------------------------------------------------------------------------- // Sub-namespaces // --------------------------------------------------------------------------- class SlotsApi { constructor(private readonly opts: AquienpzClientOptions) {} /** Resolve one slot — returns `{slot, preset, url}` or `{slot: null, url: null}` when unbound. */ resolve( slotKey: string, options: ResolveSlotOptions = {}, ): Promise { return resolveSlot(slotKey, options); } /** Bulk-resolve N slots in one HTTP round-trip. */ resolveMany( slotKeys: string[], options: ResolveSlotOptions = {}, ): Promise> { return resolveSlots(slotKeys, options); } /** List slots for the tenant (admin). Optional prefix filter for tree views. */ async list( opts: { prefix?: string; limit?: number } = {}, ): Promise { const u = endpointUrl(this.opts, "/slots", { prefix: opts.prefix, limit: opts.limit, }); const r = await fetch(u, { headers: this.headers() }); if (!r.ok) throw new Error(`slots list ${r.status}: ${await r.text()}`); const body = (await r.json()) as { slots: SlotDTO[] }; return body.slots; } /** Bind / rebind a slot to an asset. Admin-only operation. */ async bind( slotKey: string, body: { assetId: string; preset?: VariantPreset; description?: string; updatedBy?: string; }, ): Promise<{ ok: true; slotKey: string; assetId: string }> { const r = await fetch( endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`), { method: "PUT", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); if (!r.ok) throw new Error(`slot bind ${r.status}: ${await r.text()}`); return (await r.json()) as { ok: true; slotKey: string; assetId: string }; } /** * Recent bindings for a slot. Lets the admin audit who changed * what and restore a previous binding without remembering the * asset id. Default limit 20, max 100. */ async history( slotKey: string, opts: { limit?: number } = {}, ): Promise { const u = endpointUrl( this.opts, `/slots/${encodeURIComponent(slotKey)}/history`, { limit: opts.limit }, ); const r = await fetch(u, { headers: this.headers() }); if (!r.ok) throw new Error(`slots history ${r.status}: ${await r.text()}`); const body = (await r.json()) as { history: SlotHistoryEntry[] }; return body.history; } /** * Restore the slot to a previous binding. Equivalent to * `bind(key, { assetId: previous.assetId, action: "restore" })` * — the audit row is tagged `restore` instead of `bind`. */ async restore( slotKey: string, args: { assetId: string; preset?: VariantPreset; updatedBy?: string }, ): Promise<{ ok: true; slotKey: string; assetId: string }> { const r = await fetch( endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`), { method: "PUT", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify({ ...args, action: "restore" }), }, ); if (!r.ok) throw new Error(`slot restore ${r.status}: ${await r.text()}`); return (await r.json()) as { ok: true; slotKey: string; assetId: string }; } /** Remove a slot binding. The asset itself is left alone. */ async unbind(slotKey: string): Promise<{ ok: true; removed: number }> { const r = await fetch( endpointHref(this.opts, `/slots/${encodeURIComponent(slotKey)}`), { method: "DELETE", headers: this.headers(), }, ); if (!r.ok) throw new Error(`slot unbind ${r.status}: ${await r.text()}`); return (await r.json()) as { ok: true; removed: number }; } /** Invalidate the in-process cache after a slot rebind. */ invalidateCache(slotKey?: string): void { invalidateSlotCache(slotKey); } private headers(): Record { return authHeaders(this.opts); } } /** * Returned by `aq.assets.regenerate(...)`. The shape varies by kind — * images return immediately with the merged variant list; videos * return a dispatch handle (the actual transcode runs in a Cloud Run * Job and finishes async). */ export type RegenerateResult = | { ok: true; kind: "image"; /** Full variant set after the merge. */ variants: AssetVariant[]; /** Presets newly written this run. Useful for showing "added X". */ newVariants: VariantPreset[]; /** * Which source the server read to derive the new variants: * - `"original"` / `"raw"` → lossless source bytes (best) * - `"xl"` / `"lg"` / `"md"` / `"sm"` / `"thumb"` → a previously * encoded WebP variant was used as the source. Output is * re-encoded WebP — fine for thumb/sm from lg, lossier when * working from already-small sources. * * The no-upscale clamp still applies: deriving `lg` (1920) from * a 640 `sm` source produces a 640-side `lg` variant, not a * stretched 1920. */ sourceUsed: VariantPreset | "raw"; } | { ok: true; kind: "video"; dispatch: unknown; regenerated: string[] | "default"; }; /** * Wire shape returned by `POST /assets/upload-url`. Either the server * resolves the upload synchronously via dedup (`deduped: true` + existing * asset DTO) or it returns a presigned R2 PUT URL plus a `process` payload * the caller must POST to `/assets/process` after the PUT lands. */ export type UploadUrlResult = | { deduped: true; asset: AssetDTO } | { deduped: false; upload: { url: string; headers?: Record }; process: { url: string; body: Record }; }; /** * VIDEO-only delivery knobs threaded into `/assets/process`. Ignored for * image / audio / other uploads. Both fields default to today's behavior when * omitted, so existing callers are unaffected. */ export type UploadVideoOptions = { /** * `false` → skip the auto-dispatched HLS adaptive ladder (240p–2160p). Use * for download-only assets served as a progressive `-v.mp4` and never * streamed (e.g. share-video reels) — it avoids a second Cloud Run Job no * one watches. Default/absent → the ladder is generated as before. */ hls?: boolean; /** * `true` → when the uploaded MP4 is ALREADY web-safe (H.264 + yuv420p), * re-mux the `video` variant with `-c copy` instead of re-encoding. Use for * delivery-ready uploads (the bytes are already H.264 High / yuv420p / * +faststart / capped bitrate) to skip a wasteful re-encode + generational * quality loss. Falls back to a full re-encode automatically when the source * is not web-safe. Default/absent → unconditional re-encode (today's path). */ passthrough?: boolean; }; /** Input shape accepted by `aq.assets.presignUploadUrl(...)`. */ export type PresignUploadUrlOptions = { /** Full sha256 (64 hex) of the bytes that will be PUT to R2. */ sha256: string; /** MIME type of the bytes (e.g. `image/jpeg`, `video/mp4`). */ mime: string; /** Byte length of the upload payload. */ bytes: number; /** Suggested file name; surfaces in admin dashboards + extension fallback. */ fileName: string; /** * Variant ladder to generate after `/assets/process`. Defaults to * `["original"]` server-side when omitted — same contract as `aq.upload`. */ presets?: VariantPreset[]; /** * Pre-compression size of the source (useful when the browser ran * compressorjs / heic2any before computing `bytes`). Surfaces in admin * dashboards under `assets.client_original_bytes`. */ clientOriginalBytes?: number; /** VIDEO-only delivery knobs forwarded into `/assets/process`. See {@link UploadVideoOptions}. */ video?: UploadVideoOptions; }; /** Input shape accepted by `aq.assets.composeMarketing(...)`. */ export type ComposeMarketingSegment = { /** Public URL of the source clip (typically a `/t/.../video.mp4` transform). */ sourceUrl: string; /** Optional clip duration in seconds (cap for that segment). */ durationSec?: number; }; export type ComposeMarketingComposition = { /** Transition between consecutive segments. Default `"cut"`. */ transition?: "cut" | "fade"; /** Optional audio track to mix on top of the final composition. */ audioTrack?: { url: string }; /** Final composition length in seconds (server may clamp). */ finalDurationSec?: number; }; export type ComposeMarketingOptions = { /** Marketing-kit id this composition belongs to (server uses it for naming + dedup). */ marketingKitId: string; /** Ordered clip segments to stitch. */ segments: ComposeMarketingSegment[]; /** Optional composition-level knobs (transitions, audio, duration). */ composition?: ComposeMarketingComposition; }; export type ComposeMarketingResult = { /** Aquienpz asset id of the in-flight composition. Poll `aq.assets.waitReady(id)`. */ assetId: string; /** Asset status at dispatch time — usually `"processing"`. */ status: "processing" | "ready" | "failed"; }; class AssetsApi { constructor(private readonly opts: AquienpzClientOptions) {} /** Look up an asset by full sha256 (64 hex). Returns null on 404. */ async byHash(sha256: string): Promise { const r = await fetch( endpointHref(this.opts, `/assets/by-hash/${sha256}`), { headers: this.headers() }, ); if (r.status === 404) return null; if (!r.ok) throw new Error(`assets byHash ${r.status}: ${await r.text()}`); return (await r.json()) as AssetDTO; } /** Bulk lookup by sha256s. */ async byHashes( hashes: string[], ): Promise<{ existing: AssetDTO[]; missing: string[] }> { const r = await fetch(endpointHref(this.opts, "/assets/by-hashes"), { method: "POST", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify({ hashes }), }); if (!r.ok) throw new Error(`assets byHashes ${r.status}: ${await r.text()}`); return (await r.json()) as { existing: AssetDTO[]; missing: string[] }; } /** Paginated list of recent assets for the tenant. */ async list( opts: { limit?: number; cursor?: string; includeDeleted?: boolean } = {}, ): Promise<{ assets: AssetDTO[]; nextCursor: string | null; }> { const u = endpointUrl(this.opts, "/assets", { limit: opts.limit, cursor: opts.cursor, include_deleted: opts.includeDeleted ? "true" : undefined, }); const r = await fetch(u, { headers: this.headers() }); if (!r.ok) throw new Error(`assets list ${r.status}: ${await r.text()}`); return (await r.json()) as { assets: AssetDTO[]; nextCursor: string | null; }; } /** Full DTO for an asset (admin view — includes audit-only fields). */ async get(assetId: string): Promise> { const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), { headers: this.headers(), }); if (!r.ok) throw new Error(`asset get ${r.status}: ${await r.text()}`); return (await r.json()) as AssetDTO & Record; } /** * Slot bindings pointing at an asset. Use this before deleting an * asset so the admin sees which storefront slots would suddenly * resolve to nothing. */ async bindings(assetId: string): Promise< Array<{ slotKey: string; preset: VariantPreset | null; description: string | null; updatedAt: string; updatedBy: string | null; }> > { const r = await fetch(endpointHref(this.opts, `/assets/${assetId}/slots`), { headers: this.headers(), }); if (!r.ok) throw new Error(`asset bindings ${r.status}: ${await r.text()}`); const body = (await r.json()) as { slots: Array<{ slotKey: string; preset: VariantPreset | null; description: string | null; updatedAt: string; updatedBy: string | null; }>; }; return body.slots; } /** * Full variant list for an asset — preset, URL, dimensions, bytes. * Stronger-typed wrapper around `get()` that exposes only the * `variants` field with the proper `AssetVariant[]` shape. * * const v = await aq.assets.variants(logoId); * v.map((x) => x.preset); // → ("thumb" | "sm" | … | "original")[] */ async variants(assetId: string): Promise { const dto = await this.get(assetId); return Array.isArray((dto as { variants?: unknown }).variants) ? (dto as { variants: AssetVariant[] }).variants : []; } /** * Add or rebuild variants on an existing asset. Image presets are * MERGED with what's there — passing `{ presets: ["thumb"] }` adds * the thumb variant without touching `lg`, `sm`, `original`, etc. * * // Day 0: upload original-only logo * const { assetId } = await aq.upload(logoFile); // defaults to ["original"] * * // Day 7: need a thumb without re-uploading * await aq.assets.regenerate(assetId, { presets: ["thumb"] }); * * const after = await aq.assets.variants(assetId); * after.map((v) => v.preset); // → ["original", "thumb"] * * Passing no presets re-runs the FULL default pipeline for that * asset's kind (thumb+sm+md+lg for images, poster+video for video). * * If the asset was uploaded original-only and the cleanup job has * already reaped `raw/`, the route falls back to reading the source * bytes from `variants/o.` — no need to re-upload. * * Video presets are filtered to `["poster","video","aiproxy"]` and * dispatched to the Cloud Run Job (the call returns immediately * with a dispatch handle; poll `aq.assets.get(id).status` for * completion). */ async regenerate( assetId: string, opts: { presets?: VariantPreset[] } = {}, ): Promise { const r = await fetch( endpointHref(this.opts, `/assets/${assetId}/regenerate`), { method: "POST", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify(opts.presets ? { presets: opts.presets } : {}), }, ); if (!r.ok) throw new Error(`asset regenerate ${r.status}: ${await r.text()}`); return (await r.json()) as RegenerateResult; } /** Merge metadata into an asset (role / slot / description / tags). */ async patchMetadata( assetId: string, metadata: Record, ): Promise<{ ok: true; metadata: Record }> { const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), { method: "PATCH", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify({ metadata }), }); if (!r.ok) throw new Error(`asset patch ${r.status}: ${await r.text()}`); return (await r.json()) as { ok: true; metadata: Record }; } /** * Request a presigned R2 PUT URL for direct browser-side uploads. * * Mirrors the first half of `aq.upload()` — the caller (typically a * BFF / share-link dropzone) computes sha256 in the browser, then * uploads bytes straight to R2 with the returned `upload.url`, then * POSTs `process.body` to `/assets/process` (see {@link processAndWait}) * once R2 has the bytes. * * If the sha is already known to the tenant the server short-circuits * with `{ deduped: true, asset }` — no PUT needed. */ async presignUploadUrl( opts: PresignUploadUrlOptions, ): Promise { const body: Record = { sha256: opts.sha256, mime: opts.mime, bytes: opts.bytes, fileName: opts.fileName, }; if (opts.presets && opts.presets.length > 0) body.presets = opts.presets; if (opts.clientOriginalBytes != null) body.clientOriginalBytes = opts.clientOriginalBytes; if (opts.video != null) body.video = opts.video; const r = await fetch(endpointHref(this.opts, "/assets/upload-url"), { method: "POST", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`upload-url ${r.status}: ${await r.text()}`); return (await r.json()) as UploadUrlResult; } /** * Dispatch `/assets/process` with the body returned by a prior * {@link presignUploadUrl} call, then poll until the asset transitions * to `ready` or `failed`. Throws on `failed` or timeout. * * Use this when bytes were uploaded directly from the browser to R2 — * `aq.upload()` already does presign + PUT + process + wait in one * step when the server holds the bytes. */ async processAndWait( processBody: Record, opts: { timeoutMs?: number } = {}, ): Promise { const r = await fetch(endpointHref(this.opts, "/assets/process"), { method: "POST", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify(processBody), }); if (!r.ok) throw new Error(`process ${r.status}: ${await r.text()}`); const proc = (await r.json()) as { assetId?: string }; if (!proc.assetId) throw new Error("process returned no assetId"); const final = await this.waitReady(proc.assetId, opts.timeoutMs); if (final.status === "failed") { throw new Error( `processAndWait: asset ${proc.assetId} ended status=failed`, ); } return final; } /** * Poll `GET /assets/:id` until the asset transitions to `ready` or * `failed`. Returns the final DTO (whether ready OR failed — callers * decide whether to throw on `failed`). Throws on timeout. * * Default timeout is 5 minutes; videos / HLS ladders may need a * higher cap (pass `10 * 60_000` for compositions, transcodes). */ async waitReady(assetId: string, timeoutMs = 5 * 60_000): Promise { const start = Date.now(); let delay = 500; let last: AssetDTO | null = null; while (Date.now() - start < timeoutMs) { last = (await this.get(assetId)) as AssetDTO; if (last.status === "ready" || last.status === "failed") return last; await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 5_000); } if (!last) throw new Error(`waitReady: no asset ${assetId}`); throw new Error(`waitReady timeout for ${assetId}`); } /** * Dispatch `POST /assets/compose-marketing` to stitch pre-uploaded clip * segments into a single MP4 composition. Returns the processing asset * id immediately — does NOT block on completion. Callers poll via * {@link waitReady} (typical timeout: 10 min for multi-segment kits). * * Tenant scope is inherited from the SDK client; `tenantCode` is added * to the request body so the Cloud Run Job can resolve it without * re-reading the header. */ async composeMarketing( opts: ComposeMarketingOptions, ): Promise { const r = await fetch( endpointHref(this.opts, "/assets/compose-marketing"), { method: "POST", headers: { ...this.headers(), "Content-Type": "application/json" }, body: JSON.stringify({ tenantCode: this.opts.tenantCode, marketingKitId: opts.marketingKitId, segments: opts.segments, composition: opts.composition ?? {}, }), }, ); if (!r.ok) { throw new Error(`compose-marketing ${r.status}: ${await r.text()}`); } const body = (await r.json()) as { asset?: { id: string; status: "processing" | "ready" | "failed" }; assetId?: string; status?: "processing" | "ready" | "failed"; }; // Server returns `{ asset: { id, status }, dispatch? }`; some older // builds returned `{ assetId, status }` directly. Normalize both. const assetId = body.asset?.id ?? body.assetId; const status = body.asset?.status ?? body.status ?? "processing"; if (!assetId) { throw new Error("compose-marketing: response missing assetId"); } return { assetId, status }; } private headers(): Record { return authHeaders(this.opts); } } // --------------------------------------------------------------------------- // Upload helpers // --------------------------------------------------------------------------- /** * Best-effort MIME from a file name's extension. Used as a fallback for `Uint8Array` * uploads (which carry no inherent type) so they still get classified correctly instead * of silently becoming `application/octet-stream` → `kind:"other"`. Returns `null` when * the extension is unknown. */ const MIME_BY_EXT: Record = { webp: "image/webp", jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", avif: "image/avif", heic: "image/heic", heif: "image/heif", svg: "image/svg+xml", bmp: "image/bmp", tiff: "image/tiff", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/x-m4v", pdf: "application/pdf", }; export function mimeFromFileName(fileName: string | undefined): string | null { if (!fileName) return null; const ext = fileName.split(".").pop()?.toLowerCase(); return ext ? (MIME_BY_EXT[ext] ?? null) : null; } /** * Subset of compressorjs options exposed through the SDK. Re-imported here * to avoid a hard import dependency on `./web` from this top-level module * (the /web subpath uses browser-only APIs). The runtime `compress` * implementation is lazy-loaded so Node/Bun callers don't pay the bundle * cost — see `aq.upload` below. */ export type CompressOptions = { quality?: number; mimeType?: "image/jpeg" | "image/webp"; maxWidth?: number; maxHeight?: number; convertSize?: number; strict?: boolean; keepOriginalDimensions?: boolean; convertHeic?: boolean; onProgress?: ( stage: "convertingHeic" | "compressing" | "compressingKeepingDimensions", ) => void; }; export type UploadOptions = { fileName?: string; /** * MIME type of the bytes. **Only needed for a `Uint8Array` input** — a `File`/`Blob` * already carries its `.type`. Raw bytes have no inherent MIME, so without this (and * without an extension on `fileName` to infer from) they upload as * `application/octet-stream`, which the asset-manager classifies as `kind:"other"` — * meaning NO image/video variants are generated and `regenerate()` is unsupported. * Resolution order for the effective MIME: `Blob.type` → `contentType` → * inferred from `fileName`'s extension → `application/octet-stream`. * * aq.upload(bytes, { fileName: "cover.webp" }) // inferred → image/webp ✓ * aq.upload(bytes, { contentType: "image/webp" }) // explicit ✓ * aq.upload(bytes) // octet-stream → kind:"other" ⚠ */ contentType?: string; /** Computed sha256 of bytes. Skip to compute locally with WebCrypto (browser only). */ sha256?: string; /** * Client-side compression before upload. Saves user bandwidth — typical * 5–10× reduction for raw phone photos. Browser-only; in Node/Bun this * silently no-ops with a console.warn and the raw bytes upload as-is. * * - `true` → use SDK `DEFAULT_COMPRESSION_OPTIONS` (webapp-tuned) * - `CompressOptions` → merge over defaults * - `false` / omit → no compression (current default behavior) * * Implementation is lazy-imported from `@aquienpz/sdk/web` so callers * that never set `compress` don't pay the compressorjs + heic2any * bundle cost. Skipped for non-image MIMEs (video, PDF) regardless of * this option — those go to the upload pipeline raw. * * @see {@link CompressOptions} * @see https://github.com/espaciofuturoio/aquienpz/tree/main/packages/sdk#client-side-compression-browsers */ compress?: boolean | CompressOptions; /** * Variant set to generate. **Defaults to `["original"]`** — * if you omit this option, only the raw bytes land on the CDN * under the `o` path. Pass an explicit array to request more. * * Image presets (`thumb` 256 · `sm` 640 · `md` 1280 · `lg` 1920 · * `xl` 3840 · `original`): * - `["original"]` (default) → just the raw bytes. Right call for * logos / SVGs / anything you'll resize browser-side or via * `aq.assets.regenerate(id, { presets: ["thumb"] })` later. * - `["thumb","sm","md","lg"]` → the classic responsive ladder. * - `["thumb","sm","md","lg","xl"]` → add 4K. * * Video presets (`poster`, `video`, `aiproxy`): omit `aiproxy` if * the tenant doesn't need the low-res transcode for AI captioning. * * No upscaling. Each size preset is a **ceiling**; a 1080×720 source * asked for `xl` (3840) yields a 1080×720 xl variant, not a stretched * 3840-wide image. * * Idempotent: you can always add missing variants later via * `aq.assets.regenerate(id, { presets: [...] })`. The platform * stores the source so regeneration doesn't require re-uploading. */ presets?: VariantPreset[]; /** * Max time to wait for the asset to transition to `ready` (or `failed`) * after dispatch. Default `5 * 60_000` (5 min). Bump higher for large * videos / HLS transcodes — aquienpz processing time scales with input * size and per-instance CPU. * * Throws `Error("waitReady timeout for ")` if the deadline passes * without the asset transitioning. The asset row stays in aquienpz * (status="processing") and the next byHash lookup will return it once * processing completes; the caller can resume with their own poll. */ timeoutMs?: number; /** * VIDEO-only delivery knobs forwarded into `/assets/process`. See * {@link UploadVideoOptions}. Ignored for non-video uploads. * * // A delivery-ready reel: skip the unused HLS ladder + skip re-encode. * await aq.upload(mp4Bytes, { * fileName: "reel.mp4", * presets: ["poster", "video"], * video: { hls: false, passthrough: true }, * }); */ video?: UploadVideoOptions; }; /** Default preset set the SDK sends to `/assets/upload-url` when the caller omits `presets`. */ const DEFAULT_UPLOAD_PRESETS: VariantPreset[] = ["original"]; export type UploadResult = { assetId: string; sha256: string; cdnUrl: string; }; async function computeSha256( bytes: ArrayBuffer | Uint8Array | Blob, ): Promise { const buf = bytes instanceof Blob ? await bytes.arrayBuffer() : bytes instanceof Uint8Array ? (bytes.buffer as ArrayBuffer) : bytes; const digest = await crypto.subtle.digest("SHA-256", buf); return [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, "0")) .join(""); } // --------------------------------------------------------------------------- // Main facade // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Usage (Cloudinary-style consumption dashboard data) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Slot history // --------------------------------------------------------------------------- export type SlotHistoryEntry = { id: string; action: "bind" | "unbind" | "restore"; preset: VariantPreset | null; description: string | null; updatedAt: string; updatedBy: string | null; assetId: string | null; /** Resolved DTO when the asset still exists; `null` after delete / 404. */ asset: AssetDTO | null; }; export type UsageSnapshot = { tenant: { id: number; code: string }; storage: { totalBytes: number; assetCount: number }; today: UsageWindow; last30Days: UsageWindow; }; export type UsageWindow = { reads: number; writes: number; lists: number; deletes: number; admins: number; upscales: number; processes: number; bytesIn: number; }; export type UsageDailyPoint = { date: string; reads: number; writes: number; lists: number; deletes: number; processes: number; bytesIn: number; bytesStored: number; }; export type UsagePerKey = { apiKeyId: string; prefix: string | null; name: string | null; opsTotal: number; bytesTotal: number; lastSeen: string; }; class UsageApi { constructor(private readonly opts: AquienpzClientOptions) {} /** Snapshot for the active tenant — storage + today + last 30 days totals. */ async snapshot(): Promise { const r = await fetch(endpointHref(this.opts, "/usage"), { headers: this.headers(), }); if (!r.ok) throw new Error(`usage snapshot ${r.status}: ${await r.text()}`); return (await r.json()) as UsageSnapshot; } /** Daily rollup for charts — 1..365 days, default 30. */ async timeseries(days = 30): Promise<{ tenant: { id: number; code: string }; days: UsageDailyPoint[]; }> { const r = await fetch( endpointUrl(this.opts, "/usage/timeseries", { days }), { headers: this.headers() }, ); if (!r.ok) throw new Error(`usage timeseries ${r.status}: ${await r.text()}`); return (await r.json()) as { tenant: { id: number; code: string }; days: UsageDailyPoint[]; }; } /** Per-API-key breakdown for the current month. */ async keys(): Promise<{ tenant: { id: number; code: string }; monthStart: string; keys: UsagePerKey[]; }> { const r = await fetch(endpointHref(this.opts, "/usage/keys"), { headers: this.headers(), }); if (!r.ok) throw new Error(`usage keys ${r.status}: ${await r.text()}`); return (await r.json()) as { tenant: { id: number; code: string }; monthStart: string; keys: UsagePerKey[]; }; } private headers(): Record { return authHeaders(this.opts); } } export class AquienpzClient { readonly slots: SlotsApi; readonly assets: AssetsApi; readonly usage: UsageApi; /** * Effective options — read-only. Exposed so the `/web` and `/expo` * subpaths can inherit endpoint / apiKey / tenant scope from the * configured client without re-passing them per call site. */ readonly opts: AquienpzClientOptions; constructor(opts: AquienpzClientOptions) { this.opts = opts; const cdn = opts.cdnBase ?? "https://8ok.uk"; setCdnBase(cdn); // Configure the process-global tenant so every variant URL builder // (`getAssetUrl`, `urlFor`, `srcSetFor`, upload `cdnUrl`) emits the // tenant-prefixed path `//v/-.`. setTenantId(opts.tenantId); configureSlotResolver({ endpoint: opts.endpoint, apiKey: opts.apiKey, tenantCode: opts.tenantCode, }); this.slots = new SlotsApi(opts); this.assets = new AssetsApi(opts); this.usage = new UsageApi(opts); } /** Tenant id as base36 path segment (e.g. tenantId=4 → "4/v/"). */ get tenantSegment(): string { return `${this.opts.tenantId.toString(36)}/v/`; } /** Build the canonical CDN URL deterministically from sha + preset. */ urlFor(asset: Pick, preset: VariantPreset = "lg"): string { return getAssetUrl(asset, preset); } /** Build a responsive srcSet across the available image presets. */ srcSetFor(asset: Pick): string { return getAssetSrcSet(asset); } /** * Build an on-the-fly transform URL — `/t//.`. * * ## URL CONVENTION — transforms are NOT tenant-prefixed (variants are) * Two distinct delivery paths, by design: * - **Variants / presets** (`urlFor`, `srcSetFor`, upload `cdnUrl`): * `//v/-.` ← tenant-scoped (e.g. `/4/v/-lg.webp`) * - **On-the-fly transforms** (`transform`, `transformSrcSet`): * `/t//.` ← GLOBAL, no tenant segment (`/t/...`) * The transform service is content-addressed by sha + resizes from the source on demand, * so it needs no tenant in the path. Prefixing a transform URL with `//t/...` 404s. * Consumers that build URLs by hand must NOT add the tenant segment to `/t/` URLs. * * Returns the canonical `lg` variant URL when called with empty options, * so callers can swap `urlFor()` for `transform()` without thinking. * * URLs with the same params in different order produce the same R2 * cache entry (the server canonicalizes both sides). Safe to use as * stable cache keys. * * * * @see {@link TransformOptions} for the full param matrix. */ // Overload 1: no signing — synchronous, on-ladder width only (strict). transform(asset: Pick, opts?: TransformOptions): string; // Overload 2: with { sign: true } — async; returns `?sig=` URL. // Accepts SignedTransformOptions so a signed URL may carry an off-ladder // custom width (the signature earns the edge whitelist bypass). transform( asset: Pick, opts: SignedTransformOptions, signOpts: { sign: true }, ): Promise; transform( asset: Pick, opts: SignedTransformOptions = {}, signOpts?: { sign: true }, ): string | Promise { if (!signOpts?.sign) { // Unsigned path. Overload 1 constrains `width` to the ladder at every // public call site, so an off-ladder width can't reach here through the // typed API — narrow back to TransformOptions for the strict builder. return ( getTransformUrl(asset, opts as TransformOptions) ?? this.urlFor(asset, "lg") ); } if (!this.opts.signingKey) { throw new Error( "aq.transform({ sign: true }) requires `signingKey` in AquienpzClientOptions. " + "Pull the tenant's signing key from /admin/tenants/:id and pass it to the SDK constructor on a SERVER-side instance only.", ); } // Signed path — custom (off-ladder) widths allowed. Empty opts → no // transform DSL, fall back to the unsigned `lg` variant URL. return ( getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg")) ); } /** * Build a responsive `srcSet` string. One transform URL per width; all * other options apply to every URL. * * Pass `{ sign: true }` to return signed URLs (async). Without it, the * call stays synchronous as before. */ transformSrcSet( asset: Pick, widths: number[], extraOpts?: Omit, ): string; transformSrcSet( asset: Pick, widths: number[], extraOpts: Omit, signOpts: { sign: true }, ): Promise; transformSrcSet( asset: Pick, widths: number[], extraOpts: Omit = {}, signOpts?: { sign: true }, ): string | Promise { if (!signOpts?.sign) return getTransformSrcSet(asset, widths, extraOpts); if (!this.opts.signingKey) { throw new Error( "aq.transformSrcSet({ sign: true }) requires `signingKey` in AquienpzClientOptions.", ); } const key = this.opts.signingKey; return Promise.all( widths.map(async (w) => { // Signed path → off-ladder widths allowed; build+sign via the // custom-width helper (the strict `getTransformUrl` would reject a // raw `number` width). const signed = await getSignedTransformUrl( asset, { ...extraOpts, width: w }, key, ); return signed ? `${signed} ${w}w` : null; }), ).then((parts) => parts.filter((s): s is string => s != null).join(", ")); } /** * Build an on-the-fly VIDEO transform URL — Phase 4. * * Same DSL shape as `transform()` but the URL has a `.mp4` (default) * or `.webm` extension and the server routes the request to a Cloud * Run Job for ffmpeg encoding (vs the inline sharp pipeline for * images). * * On the first request the route returns **202 Accepted** with * `Retry-After: 10` while the Job runs (typically 5-30 s for a * short clip). The response body includes `outputUrl` which is the * eventual CDN URL — poll the same transform URL after the * retry-after window to get a 302 redirect to it. * * const url = aq.transformVideo(asset, { * width: 1080, height: 1920, fit: "cover", * start: 0, duration: 15, * }); * // Pass to Video.js /