@nitida/sdk 0.20.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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @nitida/sdk/native — React Native compression helpers.
3
+ *
4
+ * Thin pass-through to `@nitida/asset-compressor-native`'s
5
+ * `compressImage` / `compressImages`, kept in its own subpath so:
6
+ *
7
+ * 1. Web-only consumers (Next.js, Vite) don't pull react-native into
8
+ * their bundle when they import `@nitida/sdk`.
9
+ * 2. The native bridge stays lazy — `aq.upload(...)` on RN can
10
+ * dynamic-import this module only when `compress: true` is set.
11
+ * 3. Future native helpers (upload session resume, native
12
+ * thumbnailing, etc.) can land here without polluting `/expo`,
13
+ * which is specifically about background upload sessions.
14
+ *
15
+ * Peer deps:
16
+ * - react-native
17
+ * - react-native-compressor (with the matching Expo config plugin)
18
+ *
19
+ * Usage:
20
+ *
21
+ * import { compressImage, compressImages } from "@nitida/sdk/native";
22
+ *
23
+ * const { uri, size, originalSize } = await compressImage({
24
+ * uri: pickerAsset.uri,
25
+ * filename: pickerAsset.fileName ?? "photo.jpg",
26
+ * });
27
+ *
28
+ * const upload = createExpoUploader(aq, {
29
+ * file: { uri, mime: "image/jpeg", name: "photo.jpg" },
30
+ * });
31
+ * await upload.start();
32
+ * @module @nitida/sdk/native
33
+ */
34
+
35
+ export type {
36
+ CompressedResult,
37
+ CompressionError,
38
+ CompressionOptions,
39
+ CompressionStatusKey,
40
+ } from "@nitida/asset-compressor-native";
41
+ export {
42
+ type CompressInput,
43
+ compressImage,
44
+ compressImages,
45
+ DEFAULT_COMPRESSION_OPTIONS,
46
+ DEFAULT_NATIVE_CONCURRENCY,
47
+ MAX_UPLOAD_DIMENSION,
48
+ } from "@nitida/asset-compressor-native";
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @nitida/sdk/react — React hooks layered on top of the universal SDK.
3
+ *
4
+ * Kept in a subpath so the SSR-safe core (`@nitida/sdk`) stays
5
+ * dependency-free of react. Import only what you need:
6
+ *
7
+ * import { useSlot, useSlots, AquienpzProvider } from "@nitida/sdk/react";
8
+ *
9
+ * Pattern: wrap your app in `<AquienpzProvider client={…}>` once at
10
+ * the root; hooks read the client from context. No prop-drilling.
11
+ * @module @nitida/sdk/react
12
+ */
13
+
14
+ import {
15
+ createContext,
16
+ createElement,
17
+ type ReactNode,
18
+ useContext,
19
+ useEffect,
20
+ useMemo,
21
+ useState,
22
+ } from "react";
23
+ import type { AquienpzClient, ResolveSlotOptions, SlotResolution } from "..";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Provider
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const ClientContext = createContext<AquienpzClient | null>(null);
30
+
31
+ export function AquienpzProvider(props: {
32
+ client: AquienpzClient;
33
+ children: ReactNode;
34
+ }): ReactNode {
35
+ return createElement(
36
+ ClientContext.Provider,
37
+ { value: props.client },
38
+ props.children,
39
+ );
40
+ }
41
+
42
+ export function useAquienpzClient(): AquienpzClient {
43
+ const client = useContext(ClientContext);
44
+ if (!client) {
45
+ throw new Error(
46
+ "useAquienpzClient: wrap your app in <AquienpzProvider client={…}>.",
47
+ );
48
+ }
49
+ return client;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Slot hooks
54
+ // ---------------------------------------------------------------------------
55
+
56
+ type SlotState = {
57
+ /** Resolved DTO (`null` while loading or unbound). */
58
+ resolution: SlotResolution | null;
59
+ /** Convenience: the CDN URL, when resolved. */
60
+ url: string | null;
61
+ isLoading: boolean;
62
+ error: Error | null;
63
+ };
64
+
65
+ const emptyState: SlotState = {
66
+ resolution: null,
67
+ url: null,
68
+ isLoading: true,
69
+ error: null,
70
+ };
71
+
72
+ /**
73
+ * Subscribe to a single slot. Re-resolves when the key changes or the
74
+ * cache is invalidated. Returns `{resolution, url, isLoading, error}`.
75
+ */
76
+ export function useSlot(
77
+ slotKey: string,
78
+ options: ResolveSlotOptions = {},
79
+ ): SlotState {
80
+ const client = useAquienpzClient();
81
+ const [state, setState] = useState<SlotState>(emptyState);
82
+
83
+ // Stabilize options across renders so the effect only refires on the
84
+ // values that matter.
85
+ const presetKey = options.preset ?? "";
86
+ const ttlMs = options.ttlMs ?? 60_000;
87
+
88
+ useEffect(() => {
89
+ let alive = true;
90
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
91
+ client.slots
92
+ .resolve(slotKey, { preset: options.preset, ttlMs })
93
+ .then((resolution) => {
94
+ if (!alive) return;
95
+ setState({
96
+ resolution,
97
+ url: resolution.url,
98
+ isLoading: false,
99
+ error: null,
100
+ });
101
+ })
102
+ .catch((err: unknown) => {
103
+ if (!alive) return;
104
+ setState({
105
+ resolution: null,
106
+ url: null,
107
+ isLoading: false,
108
+ error: err instanceof Error ? err : new Error(String(err)),
109
+ });
110
+ });
111
+ return () => {
112
+ alive = false;
113
+ };
114
+ }, [client, slotKey, presetKey, ttlMs, options.preset]);
115
+
116
+ return state;
117
+ }
118
+
119
+ /**
120
+ * Bulk version — fetches N keys in one round-trip. Returns a map keyed
121
+ * by slot key. Pass a STABLE array reference (memoize with useMemo) to
122
+ * avoid re-fetches on every render.
123
+ */
124
+ export function useSlots(
125
+ slotKeys: string[],
126
+ options: ResolveSlotOptions = {},
127
+ ): {
128
+ resolutions: Record<string, SlotResolution>;
129
+ isLoading: boolean;
130
+ error: Error | null;
131
+ } {
132
+ const client = useAquienpzClient();
133
+ const keysHash = useMemo(() => slotKeys.join("|"), [slotKeys]);
134
+ const presetKey = options.preset ?? "";
135
+ const ttlMs = options.ttlMs ?? 60_000;
136
+
137
+ const [state, setState] = useState<{
138
+ resolutions: Record<string, SlotResolution>;
139
+ isLoading: boolean;
140
+ error: Error | null;
141
+ }>({ resolutions: {}, isLoading: true, error: null });
142
+
143
+ useEffect(() => {
144
+ let alive = true;
145
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
146
+ client.slots
147
+ .resolveMany(slotKeys, { preset: options.preset, ttlMs })
148
+ .then((resolutions) => {
149
+ if (!alive) return;
150
+ setState({ resolutions, isLoading: false, error: null });
151
+ })
152
+ .catch((err: unknown) => {
153
+ if (!alive) return;
154
+ setState({
155
+ resolutions: {},
156
+ isLoading: false,
157
+ error: err instanceof Error ? err : new Error(String(err)),
158
+ });
159
+ });
160
+ return () => {
161
+ alive = false;
162
+ };
163
+ // slotKeys is hashed via keysHash; using it directly here would
164
+ // re-fire on every render (new array identity each render).
165
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keysHash captures slotKeys identity
166
+ }, [client, keysHash, presetKey, ttlMs]);
167
+
168
+ return state;
169
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @nitida/sdk/server — server-safe entry point.
3
+ *
4
+ * Use this subpath from Node.js, Bun, Cloud Run, Lambda, Vercel Functions,
5
+ * edge runtimes, agents, cron jobs, BFFs — anywhere there's no `window`
6
+ * and you want a hard guarantee that no browser-only code lands in your
7
+ * bundle. The constructor REQUIRES `apiKey`; the type from `/web` omits
8
+ * it, so the two modes never confuse each other.
9
+ *
10
+ * import { AquienpzClient } from "@nitida/sdk/server";
11
+ *
12
+ * const aq = new AquienpzClient({
13
+ * endpoint: process.env.ASSET_MANAGER_URL!,
14
+ * apiKey: process.env.ASSET_MANAGER_API_KEY!, // <- required
15
+ * tenantCode: "realtyone-cr",
16
+ * tenantId: 1,
17
+ * // signingKey: optional, only for `aq.transform(..., { sign: true })`
18
+ * });
19
+ *
20
+ * const asset = await aq.assets.byHash(sha256);
21
+ * const hero = aq.transform(asset, { width: 1920 });
22
+ *
23
+ * // Typical BFF use: proxy a browser request through to aquienpz.
24
+ * // The browser side calls `@nitida/sdk/web` against `/api/am/...`
25
+ * // and your route handler forwards here with the real API key.
26
+ *
27
+ * What you get:
28
+ * - `AquienpzClient` (slots/assets/usage APIs over plain fetch)
29
+ * - URL builders: `getAssetUrl`, `getTransformUrl`, `getTransformSrcSet`,
30
+ * `getHlsStreamingUrl`, `extractAssetSha`, `signTransformUrl`
31
+ * - `aq.upload(bytes)` works with `Uint8Array` (Node 18+ / Bun ship Blob
32
+ * globally; File-API workflows are documented on the /web subpath instead)
33
+ *
34
+ * What's NOT here (use `@nitida/sdk/web` instead):
35
+ * - `createWebUploader` (multipart UploadTask with IndexedDB resume)
36
+ * - `compressImage` (browser-side compressorjs + heic2any)
37
+ *
38
+ * Stripe/Cloudinary historically shipped two separate packages
39
+ * (`stripe` vs `@stripe/stripe-js`, `cloudinary` vs `@cloudinary/url-gen`)
40
+ * for this split. Modern providers (Vercel Blob, Uploadthing, Better
41
+ * Auth, AI SDK) use subpaths within one package — same tree-shaking
42
+ * guarantees, single version, no drift. We follow that pattern.
43
+ * @module @nitida/sdk/server
44
+ */
45
+
46
+ import {
47
+ type AquienpzClientOptions,
48
+ AquienpzClient as BaseAquienpzClient,
49
+ } from "..";
50
+
51
+ /**
52
+ * Server-side constructor options — `apiKey` is REQUIRED here. Use this
53
+ * type whenever you build a client behind a process boundary (Node, Bun,
54
+ * Cloud Run, Vercel Functions, edge runtimes, BFFs).
55
+ *
56
+ * const aq = new AquienpzClient({
57
+ * endpoint: process.env.ASSET_MANAGER_URL!,
58
+ * apiKey: process.env.ASSET_MANAGER_API_KEY!,
59
+ * tenantCode: "realtyone-cr",
60
+ * tenantId: 1,
61
+ * });
62
+ */
63
+ export type ServerClientOptions = Required<
64
+ Pick<AquienpzClientOptions, "endpoint" | "apiKey" | "tenantCode" | "tenantId">
65
+ > &
66
+ Pick<AquienpzClientOptions, "cdnBase" | "signingKey">;
67
+
68
+ /**
69
+ * Server-safe `AquienpzClient` — same runtime as the root class, but the
70
+ * constructor type enforces `apiKey` so misconfiguration is a TS build
71
+ * error, not a runtime 401.
72
+ */
73
+ export class AquienpzClient extends BaseAquienpzClient {
74
+ constructor(opts: ServerClientOptions) {
75
+ super(opts);
76
+ }
77
+ }
78
+
79
+ export {
80
+ type AquienpzClientOptions,
81
+ type AssetDTO,
82
+ type AssetVariant,
83
+ type ComposeMarketingComposition,
84
+ type ComposeMarketingOptions,
85
+ type ComposeMarketingResult,
86
+ type ComposeMarketingSegment,
87
+ type CompressOptions,
88
+ // URL builders & related types (re-export from asset-client via root).
89
+ computeVariantDimensions,
90
+ extractAssetSha,
91
+ getAssetDimensions,
92
+ getAssetSrcSet,
93
+ getAssetUrl,
94
+ getHlsStreamingUrl,
95
+ getSignedTransformUrl,
96
+ getTenantId,
97
+ getTransformSrcSet,
98
+ getTransformUrl,
99
+ getVideoTransformUrl,
100
+ hasPreset,
101
+ type PresignUploadUrlOptions,
102
+ type RegenerateResult,
103
+ type ResolveSlotOptions,
104
+ type SignedTransformOptions,
105
+ type SlotDTO,
106
+ type SlotHistoryEntry,
107
+ type SlotResolution,
108
+ serializeTransform,
109
+ setTenantId,
110
+ signTransformUrl,
111
+ type TransformEffect,
112
+ type TransformFit,
113
+ type TransformFormat,
114
+ type TransformGravity,
115
+ type TransformOptions,
116
+ type UploadOptions,
117
+ type UploadResult,
118
+ type UploadUrlResult,
119
+ type UsageDailyPoint,
120
+ type UsagePerKey,
121
+ type UsageSnapshot,
122
+ type UsageWindow,
123
+ type VariantPreset,
124
+ } from "..";
@@ -0,0 +1,334 @@
1
+ /**
2
+ * @nitida/sdk/web — browser entry point (BFF-proxy mode).
3
+ *
4
+ * Use this subpath from any browser context (Next.js client components,
5
+ * Vite/CRA SPAs, browser extensions, web workers — anywhere `window`
6
+ * exists). Bundles the core `AquienpzClient` PLUS browser-only helpers
7
+ * (client-side image compression).
8
+ *
9
+ * // 1. In a client component (browser):
10
+ * import { AquienpzClient } from "@nitida/sdk/web";
11
+ *
12
+ * const aq = new AquienpzClient({
13
+ * // Point at your BFF route — the SDK calls
14
+ * // `${endpoint}/assets/by-hash/...`, `${endpoint}/slots/...`, etc.
15
+ * endpoint: "/api/am", // relative ⇒ same-origin proxy
16
+ * tenantCode: "realtyone-cr",
17
+ * tenantId: 1,
18
+ * // NO apiKey — the type strips it. Your BFF injects the bearer.
19
+ * });
20
+ *
21
+ * const result = await aq.upload(file, { compress: true });
22
+ *
23
+ * // 2. In a Next.js route handler (server) — proxy through to aquienpz:
24
+ * // app/api/am/[...path]/route.ts
25
+ * export async function GET(req: Request, { params }: ...) {
26
+ * const path = params.path.join("/");
27
+ * const url = `${process.env.AQUIENPZ_URL}/${path}${new URL(req.url).search}`;
28
+ * return fetch(url, {
29
+ * headers: {
30
+ * Authorization: `Bearer ${process.env.AQUIENPZ_API_KEY!}`,
31
+ * "X-Tenant-Code": "realtyone-cr",
32
+ * },
33
+ * });
34
+ * }
35
+ *
36
+ * Why no `apiKey` in the type: in browser code, a long-lived bearer key
37
+ * would be shipped to every visitor in `NEXT_PUBLIC_*`. The type omits
38
+ * it physically (TS build error at the call site) so security mistakes
39
+ * are loud instead of silent. Same pattern as `@vercel/blob/client`,
40
+ * `better-auth/client`, AI SDK's `/edge` subpath.
41
+ *
42
+ * Need to ship a server-rendered storefront with a long-lived key?
43
+ * Import from `@nitida/sdk/server` instead — that subpath requires
44
+ * `apiKey` and bundles cleanly only in Node/Bun/edge runtimes.
45
+ *
46
+ * Peer deps (auto-installed via npm peer deps — declared optional):
47
+ * - `@aquienpz/asset-uploader-web` for multipart uploads
48
+ * - `@nitida/asset-compressor-web` for client-side compression
49
+ *
50
+ * Both are lazy-imported; bundles that never call into them skip the cost.
51
+ * @module @nitida/sdk/web
52
+ */
53
+
54
+ import {
55
+ type AquienpzClientOptions,
56
+ AquienpzClient as BaseAquienpzClient,
57
+ } from "..";
58
+
59
+ /**
60
+ * Browser-safe constructor options for `@nitida/sdk/web`.
61
+ *
62
+ * Identical to the root `AquienpzClientOptions` except `apiKey` and
63
+ * `signingKey` are **physically absent** — passing them is a TypeScript
64
+ * build error, not a runtime warning. In BFF-proxy mode your route
65
+ * handler injects the bearer header server-side; the browser never
66
+ * sees the long-lived key.
67
+ *
68
+ * const aq = new AquienpzClient({
69
+ * endpoint: "/api/am", // OK: relative → same-origin BFF
70
+ * tenantCode: "realtyone-cr",
71
+ * tenantId: 1,
72
+ * // apiKey: "amk_rt_...", // ERROR: TS error: not assignable
73
+ * });
74
+ */
75
+ export type WebClientOptions = Omit<
76
+ AquienpzClientOptions,
77
+ "apiKey" | "signingKey"
78
+ >;
79
+
80
+ /**
81
+ * Browser-safe `AquienpzClient` — same runtime as the root class, but
82
+ * the constructor's type rejects `apiKey` / `signingKey`. Calls go
83
+ * through your BFF (typically a same-origin route like `/api/am/...`).
84
+ *
85
+ * For server-side instantiation (Node/Bun/edge), import from
86
+ * `@nitida/sdk/server` instead.
87
+ */
88
+ export class AquienpzClient extends BaseAquienpzClient {
89
+ constructor(opts: WebClientOptions) {
90
+ super(opts as AquienpzClientOptions);
91
+ }
92
+ }
93
+
94
+ // Re-export the rest of the surface (URL builders, types, etc.) so
95
+ // browser consumers can satisfy 100% of their needs through this
96
+ // single subpath.
97
+ export {
98
+ type AssetDTO,
99
+ type AssetVariant,
100
+ type CompressOptions as ClientCompressOptions,
101
+ computeVariantDimensions,
102
+ extractAssetSha,
103
+ getAssetDimensions,
104
+ getAssetSrcSet,
105
+ getAssetUrl,
106
+ getHlsStreamingUrl,
107
+ getSignedTransformUrl,
108
+ getTransformSrcSet,
109
+ getTransformUrl,
110
+ getVideoTransformUrl,
111
+ hasPreset,
112
+ type RegenerateResult,
113
+ type ResolveSlotOptions,
114
+ type SignedTransformOptions,
115
+ type SlotDTO,
116
+ type SlotHistoryEntry,
117
+ type SlotResolution,
118
+ serializeTransform,
119
+ signTransformUrl,
120
+ type TransformEffect,
121
+ type TransformFit,
122
+ type TransformFormat,
123
+ type TransformGravity,
124
+ type TransformOptions,
125
+ type UploadOptions,
126
+ type UploadResult,
127
+ type UsageDailyPoint,
128
+ type UsagePerKey,
129
+ type UsageSnapshot,
130
+ type UsageWindow,
131
+ type VariantPreset,
132
+ } from "..";
133
+
134
+ // Multipart uploader helpers are NOT re-exported from this subpath.
135
+ //
136
+ // `@aquienpz/asset-uploader-web` is an optional peer-dep that isn't on
137
+ // npm yet, so a static `import` at module top broke every consumer of
138
+ // `@nitida/sdk/web` (the bundler tries to resolve before the optional
139
+ // peer check kicks in). The old `createWebUploader` + `UploadTask`
140
+ // re-exports also don't fit BFF-proxy mode (they need a raw `authToken`
141
+ // the BFF doesn't expose). The cleaner story is:
142
+ //
143
+ // - Files ≤50MB → `aq.upload(file)` (single PUT, atomic, BFF-friendly)
144
+ // - Files >50MB → roadmap item for v0.18 (BFF-minted short-lived token
145
+ // + resumable multipart). Track at
146
+ // https://github.com/espaciofuturoio/aquienpz/issues
147
+ //
148
+ // Apps that need `UploadTask` directly today should depend on
149
+ // `@aquienpz/asset-uploader-web` themselves once it's published.
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Client-side compression (Phase 1.5)
153
+ // ---------------------------------------------------------------------------
154
+ //
155
+ // Wraps `@nitida/asset-compressor-web` with the SDK's
156
+ // `DEFAULT_COMPRESSION_OPTIONS` — values derived from the realtyone-cr
157
+ // webapp's production tuning (LISTING_STANDARD_*: quality 0.80, max-edge
158
+ // 3840px, WebP output, 5MB PNG→JPEG threshold, strict mode). Those defaults
159
+ // differ from `@nitida/asset-compressor-web`'s own defaults (0.85 / 2880 /
160
+ // convertSize=0) which target a slightly different audience; SDK callers
161
+ // get the webapp-tuned values, package-direct callers keep theirs.
162
+
163
+ // Types inlined from `@nitida/asset-compressor-web` to avoid even a
164
+ // type-only `import` of the optional peer dep — TypeScript's `import type`
165
+ // is erased at runtime, but some bundlers (Turbopack as of 2026-05) still
166
+ // scan and warn on the path. Keeping these types local makes `/web`
167
+ // importable in any project regardless of whether the compressor is
168
+ // installed.
169
+ type CompressionOptions = {
170
+ quality?: number;
171
+ mimeType?: "image/jpeg" | "image/webp";
172
+ maxWidth?: number;
173
+ maxHeight?: number;
174
+ convertSize?: number;
175
+ keepOriginalDimensions?: boolean;
176
+ };
177
+ type CompressionStatusKey =
178
+ | "convertingHeic"
179
+ | "compressing"
180
+ | "compressingKeepingDimensions"
181
+ | (string & {});
182
+
183
+ export type CompressStage =
184
+ | "convertingHeic"
185
+ | "compressing"
186
+ | "compressingKeepingDimensions";
187
+
188
+ /**
189
+ * Subset of compressorjs options exposed by the SDK. Matches the
190
+ * webapp's `DEFAULT_COMPRESSION_OPTIONS` shape from the realtyone-cr
191
+ * production back-office uploader.
192
+ */
193
+ export type CompressOptions = {
194
+ /** 0..1. Default 0.80 (`LISTING_STANDARD_IMAGE_QUALITY / 100`). */
195
+ quality?: number;
196
+ /** Output format. Default `"image/webp"` (`DEFAULT_IMAGE_TARGET_FORMAT`). */
197
+ mimeType?: "image/jpeg" | "image/webp";
198
+ /** Max edge in pixels. Default 3840 (`LISTING_STANDARD_IMAGE_WIDTH`). */
199
+ maxWidth?: number;
200
+ /** Default 3840 (`LISTING_STANDARD_IMAGE_HEIGHT`). */
201
+ maxHeight?: number;
202
+ /**
203
+ * compressorjs `convertSize`: PNG > this byte count auto-converts to
204
+ * JPEG before the quality pass. Default 5 MB (5 * 1024 * 1024).
205
+ */
206
+ convertSize?: number;
207
+ /** compressorjs strict mode. Default true. */
208
+ strict?: boolean;
209
+ /** Skip the resize pass (keep original dimensions). Default false. */
210
+ keepOriginalDimensions?: boolean;
211
+ /**
212
+ * Convert HEIC/HEIF inputs to JPEG via heic2any first. Default true.
213
+ * Has no effect on non-HEIC inputs.
214
+ */
215
+ convertHeic?: boolean;
216
+ /** Optional progress hook. Useful for UI status indicators. */
217
+ onProgress?: (stage: CompressStage) => void;
218
+ };
219
+
220
+ /**
221
+ * Webapp-tuned defaults (LISTING_STANDARD_*). These intentionally differ
222
+ * from `@nitida/asset-compressor-web`'s package-level defaults — the
223
+ * SDK overrides at call time.
224
+ */
225
+ export const DEFAULT_COMPRESSION_OPTIONS: Required<
226
+ Omit<CompressOptions, "keepOriginalDimensions" | "convertHeic" | "onProgress">
227
+ > = {
228
+ quality: 0.8,
229
+ mimeType: "image/webp",
230
+ maxWidth: 3840,
231
+ maxHeight: 3840,
232
+ convertSize: 5 * 1024 * 1024,
233
+ strict: true,
234
+ };
235
+
236
+ export type CompressResult = {
237
+ /** Compressed bytes wrapped as a Blob (browser-native). */
238
+ blob: Blob;
239
+ /** Size of the source before compression, in bytes. */
240
+ originalBytes: number;
241
+ };
242
+
243
+ /**
244
+ * Compress a single image. Browser-only; in Node/Bun this is a no-op that
245
+ * returns the input unchanged (with a warning).
246
+ *
247
+ * Dynamically imports `@nitida/asset-compressor-web` so apps that never
248
+ * call `compress: true` don't pay the compressorjs + heic2any bundle cost.
249
+ */
250
+ export async function compressImage(
251
+ file: File | Blob,
252
+ opts: CompressOptions = {},
253
+ ): Promise<CompressResult> {
254
+ if (typeof window === "undefined") {
255
+ console.warn(
256
+ "[@nitida/sdk/web] compressImage called outside a browser — returning input unchanged.",
257
+ );
258
+ return { blob: file, originalBytes: file.size };
259
+ }
260
+ const finalOpts: Required<
261
+ Omit<
262
+ CompressOptions,
263
+ "keepOriginalDimensions" | "convertHeic" | "onProgress"
264
+ >
265
+ > = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts };
266
+ // Map SDK CompressOptions → package CompressionOptions, omitting fields
267
+ // the package doesn't understand (`strict`, `convertHeic`).
268
+ const packageOpts: CompressionOptions = {
269
+ quality: finalOpts.quality,
270
+ mimeType: finalOpts.mimeType,
271
+ maxWidth: finalOpts.maxWidth,
272
+ maxHeight: finalOpts.maxHeight,
273
+ convertSize: finalOpts.convertSize,
274
+ keepOriginalDimensions: opts.keepOriginalDimensions ?? false,
275
+ };
276
+
277
+ // String indirection defeats bundler static-analysis of dynamic
278
+ // imports — Turbopack/webpack will leave this for the runtime to
279
+ // resolve instead of failing the build when the optional peer dep
280
+ // isn't installed. See feedback_bun_compile_dynamic_imports (same
281
+ // trick used in reverse to opt OUT of bundling).
282
+ const compressorPkg = "@nitida/asset-compressor-web";
283
+ const { compressImage: doCompress } = (await import(
284
+ /* @vite-ignore */ /* webpackIgnore: true */ compressorPkg
285
+ )) as {
286
+ compressImage: (
287
+ args: unknown,
288
+ ) => Promise<{ blob: Blob; originalSize: number }>;
289
+ };
290
+ const r = await doCompress({
291
+ blob: file,
292
+ filename: file instanceof File ? file.name : "blob",
293
+ options: packageOpts,
294
+ statusCallback: opts.onProgress
295
+ ? (status: CompressionStatusKey) => {
296
+ if (
297
+ status === "convertingHeic" ||
298
+ status === "compressing" ||
299
+ status === "compressingKeepingDimensions"
300
+ ) {
301
+ opts.onProgress?.(status as CompressStage);
302
+ }
303
+ }
304
+ : undefined,
305
+ });
306
+ return { blob: r.blob, originalBytes: r.originalSize };
307
+ }
308
+
309
+ /**
310
+ * Compress a batch of images with adaptive concurrency
311
+ * (navigator.hardwareConcurrency, capped at 8). Failures throw — pass
312
+ * `try/catch` per file if you want per-file resilience, or call
313
+ * `compressImage` in a loop yourself.
314
+ */
315
+ export async function compressImages(
316
+ files: Array<File | Blob>,
317
+ opts: CompressOptions = {},
318
+ ): Promise<CompressResult[]> {
319
+ if (typeof window === "undefined") {
320
+ console.warn(
321
+ "[@nitida/sdk/web] compressImages called outside a browser — returning inputs unchanged.",
322
+ );
323
+ return files.map((f) => ({ blob: f, originalBytes: f.size }));
324
+ }
325
+ // Sequential per-file via the single-file helper — keeps the cancellation
326
+ // story simple and the API surface narrow. The package's
327
+ // `compressImages` adds an array-of-CompressedResult shape with
328
+ // per-file ids that's overkill for the SDK's stated surface.
329
+ const results: CompressResult[] = [];
330
+ for (const f of files) {
331
+ results.push(await compressImage(f, opts));
332
+ }
333
+ return results;
334
+ }