/** * @aquienpz/sdk/web — browser entry point (BFF-proxy mode). * * Use this subpath from any browser context (Next.js client components, * Vite/CRA SPAs, browser extensions, web workers — anywhere `window` * exists). Bundles the core `AquienpzClient` PLUS browser-only helpers * (client-side image compression). * * // 1. In a client component (browser): * import { AquienpzClient } from "@aquienpz/sdk/web"; * * const aq = new AquienpzClient({ * // Point at your BFF route — the SDK calls * // `${endpoint}/assets/by-hash/...`, `${endpoint}/slots/...`, etc. * endpoint: "/api/am", // relative ⇒ same-origin proxy * tenantCode: "realtyone-cr", * tenantId: 1, * // NO apiKey — the type strips it. Your BFF injects the bearer. * }); * * const result = await aq.upload(file, { compress: true }); * * // 2. In a Next.js route handler (server) — proxy through to aquienpz: * // app/api/am/[...path]/route.ts * export async function GET(req: Request, { params }: ...) { * const path = params.path.join("/"); * const url = `${process.env.AQUIENPZ_URL}/${path}${new URL(req.url).search}`; * return fetch(url, { * headers: { * Authorization: `Bearer ${process.env.AQUIENPZ_API_KEY!}`, * "X-Tenant-Code": "realtyone-cr", * }, * }); * } * * Why no `apiKey` in the type: in browser code, a long-lived bearer key * would be shipped to every visitor in `NEXT_PUBLIC_*`. The type omits * it physically (TS build error at the call site) so security mistakes * are loud instead of silent. Same pattern as `@vercel/blob/client`, * `better-auth/client`, AI SDK's `/edge` subpath. * * Need to ship a server-rendered storefront with a long-lived key? * Import from `@aquienpz/sdk/server` instead — that subpath requires * `apiKey` and bundles cleanly only in Node/Bun/edge runtimes. * * Peer deps (auto-installed via npm peer deps — declared optional): * - `@aquienpz/asset-uploader-web` for multipart uploads * - `@aquienpz/asset-compressor-web` for client-side compression * * Both are lazy-imported; bundles that never call into them skip the cost. */ import { type AquienpzClientOptions, AquienpzClient as BaseAquienpzClient, } from ".."; /** * Browser-safe constructor options for `@aquienpz/sdk/web`. * * Identical to the root `AquienpzClientOptions` except `apiKey` and * `signingKey` are **physically absent** — passing them is a TypeScript * build error, not a runtime warning. In BFF-proxy mode your route * handler injects the bearer header server-side; the browser never * sees the long-lived key. * * const aq = new AquienpzClient({ * endpoint: "/api/am", // OK: relative → same-origin BFF * tenantCode: "realtyone-cr", * tenantId: 1, * // apiKey: "amk_rt_...", // ERROR: TS error: not assignable * }); */ export type WebClientOptions = Omit< AquienpzClientOptions, "apiKey" | "signingKey" >; /** * Browser-safe `AquienpzClient` — same runtime as the root class, but * the constructor's type rejects `apiKey` / `signingKey`. Calls go * through your BFF (typically a same-origin route like `/api/am/...`). * * For server-side instantiation (Node/Bun/edge), import from * `@aquienpz/sdk/server` instead. */ export class AquienpzClient extends BaseAquienpzClient { constructor(opts: WebClientOptions) { super(opts as AquienpzClientOptions); } } // Re-export the rest of the surface (URL builders, types, etc.) so // browser consumers can satisfy 100% of their needs through this // single subpath. export { type AssetDTO, type AssetVariant, type CompressOptions as ClientCompressOptions, computeVariantDimensions, extractAssetSha, getAssetDimensions, getAssetSrcSet, getAssetUrl, getHlsStreamingUrl, getSignedTransformUrl, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, type RegenerateResult, type ResolveSlotOptions, type SignedTransformOptions, type SlotDTO, type SlotHistoryEntry, type SlotResolution, serializeTransform, signTransformUrl, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type UploadOptions, type UploadResult, type UsageDailyPoint, type UsagePerKey, type UsageSnapshot, type UsageWindow, type VariantPreset, } from ".."; // Multipart uploader helpers are NOT re-exported from this subpath. // // `@aquienpz/asset-uploader-web` is an optional peer-dep that isn't on // npm yet, so a static `import` at module top broke every consumer of // `@aquienpz/sdk/web` (the bundler tries to resolve before the optional // peer check kicks in). The old `createWebUploader` + `UploadTask` // re-exports also don't fit BFF-proxy mode (they need a raw `authToken` // the BFF doesn't expose). The cleaner story is: // // - Files ≤50MB → `aq.upload(file)` (single PUT, atomic, BFF-friendly) // - Files >50MB → roadmap item for v0.18 (BFF-minted short-lived token // + resumable multipart). Track at // https://github.com/espaciofuturoio/aquienpz/issues // // Apps that need `UploadTask` directly today should depend on // `@aquienpz/asset-uploader-web` themselves once it's published. // --------------------------------------------------------------------------- // Client-side compression (Phase 1.5) // --------------------------------------------------------------------------- // // Wraps `@aquienpz/asset-compressor-web` with the SDK's // `DEFAULT_COMPRESSION_OPTIONS` — values derived from the realtyone-cr // webapp's production tuning (LISTING_STANDARD_*: quality 0.80, max-edge // 3840px, WebP output, 5MB PNG→JPEG threshold, strict mode). Those defaults // differ from `@aquienpz/asset-compressor-web`'s own defaults (0.85 / 2880 / // convertSize=0) which target a slightly different audience; SDK callers // get the webapp-tuned values, package-direct callers keep theirs. // Types inlined from `@aquienpz/asset-compressor-web` to avoid even a // type-only `import` of the optional peer dep — TypeScript's `import type` // is erased at runtime, but some bundlers (Turbopack as of 2026-05) still // scan and warn on the path. Keeping these types local makes `/web` // importable in any project regardless of whether the compressor is // installed. type CompressionOptions = { quality?: number; mimeType?: "image/jpeg" | "image/webp"; maxWidth?: number; maxHeight?: number; convertSize?: number; keepOriginalDimensions?: boolean; }; type CompressionStatusKey = | "convertingHeic" | "compressing" | "compressingKeepingDimensions" | (string & {}); export type CompressStage = | "convertingHeic" | "compressing" | "compressingKeepingDimensions"; /** * Subset of compressorjs options exposed by the SDK. Matches the * webapp's `DEFAULT_COMPRESSION_OPTIONS` shape from the realtyone-cr * production back-office uploader. */ export type CompressOptions = { /** 0..1. Default 0.80 (`LISTING_STANDARD_IMAGE_QUALITY / 100`). */ quality?: number; /** Output format. Default `"image/webp"` (`DEFAULT_IMAGE_TARGET_FORMAT`). */ mimeType?: "image/jpeg" | "image/webp"; /** Max edge in pixels. Default 3840 (`LISTING_STANDARD_IMAGE_WIDTH`). */ maxWidth?: number; /** Default 3840 (`LISTING_STANDARD_IMAGE_HEIGHT`). */ maxHeight?: number; /** * compressorjs `convertSize`: PNG > this byte count auto-converts to * JPEG before the quality pass. Default 5 MB (5 * 1024 * 1024). */ convertSize?: number; /** compressorjs strict mode. Default true. */ strict?: boolean; /** Skip the resize pass (keep original dimensions). Default false. */ keepOriginalDimensions?: boolean; /** * Convert HEIC/HEIF inputs to JPEG via heic2any first. Default true. * Has no effect on non-HEIC inputs. */ convertHeic?: boolean; /** Optional progress hook. Useful for UI status indicators. */ onProgress?: (stage: CompressStage) => void; }; /** * Webapp-tuned defaults (LISTING_STANDARD_*). These intentionally differ * from `@aquienpz/asset-compressor-web`'s package-level defaults — the * SDK overrides at call time. */ export const DEFAULT_COMPRESSION_OPTIONS: Required< Omit > = { quality: 0.8, mimeType: "image/webp", maxWidth: 3840, maxHeight: 3840, convertSize: 5 * 1024 * 1024, strict: true, }; export type CompressResult = { /** Compressed bytes wrapped as a Blob (browser-native). */ blob: Blob; /** Size of the source before compression, in bytes. */ originalBytes: number; }; /** * Compress a single image. Browser-only; in Node/Bun this is a no-op that * returns the input unchanged (with a warning). * * Dynamically imports `@aquienpz/asset-compressor-web` so apps that never * call `compress: true` don't pay the compressorjs + heic2any bundle cost. */ export async function compressImage( file: File | Blob, opts: CompressOptions = {}, ): Promise { if (typeof window === "undefined") { console.warn( "[@aquienpz/sdk/web] compressImage called outside a browser — returning input unchanged.", ); return { blob: file, originalBytes: file.size }; } const finalOpts: Required< Omit< CompressOptions, "keepOriginalDimensions" | "convertHeic" | "onProgress" > > = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts }; // Map SDK CompressOptions → package CompressionOptions, omitting fields // the package doesn't understand (`strict`, `convertHeic`). const packageOpts: CompressionOptions = { quality: finalOpts.quality, mimeType: finalOpts.mimeType, maxWidth: finalOpts.maxWidth, maxHeight: finalOpts.maxHeight, convertSize: finalOpts.convertSize, keepOriginalDimensions: opts.keepOriginalDimensions ?? false, }; // String indirection defeats bundler static-analysis of dynamic // imports — Turbopack/webpack will leave this for the runtime to // resolve instead of failing the build when the optional peer dep // isn't installed. See feedback_bun_compile_dynamic_imports (same // trick used in reverse to opt OUT of bundling). const compressorPkg = "@aquienpz/asset-compressor-web"; const { compressImage: doCompress } = (await import( /* @vite-ignore */ /* webpackIgnore: true */ compressorPkg )) as { compressImage: ( args: unknown, ) => Promise<{ blob: Blob; originalSize: number }>; }; const r = await doCompress({ blob: file, filename: file instanceof File ? file.name : "blob", options: packageOpts, statusCallback: opts.onProgress ? (status: CompressionStatusKey) => { if ( status === "convertingHeic" || status === "compressing" || status === "compressingKeepingDimensions" ) { opts.onProgress?.(status as CompressStage); } } : undefined, }); return { blob: r.blob, originalBytes: r.originalSize }; } /** * Compress a batch of images with adaptive concurrency * (navigator.hardwareConcurrency, capped at 8). Failures throw — pass * `try/catch` per file if you want per-file resilience, or call * `compressImage` in a loop yourself. */ export async function compressImages( files: Array, opts: CompressOptions = {}, ): Promise { if (typeof window === "undefined") { console.warn( "[@aquienpz/sdk/web] compressImages called outside a browser — returning inputs unchanged.", ); return files.map((f) => ({ blob: f, originalBytes: f.size })); } // Sequential per-file via the single-file helper — keeps the cancellation // story simple and the API surface narrow. The package's // `compressImages` adds an array-of-CompressedResult shape with // per-file ids that's overkill for the SDK's stated surface. const results: CompressResult[] = []; for (const f of files) { results.push(await compressImage(f, opts)); } return results; }