@zenith-open/zenithcms-sdk 1.0.0 → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aman T Shekar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ import type { ZenithCollections } from '@zenith-open/zenithcms-types';
2
+ /** Resolve a collection name string to its document type, falling back to any for unknown collections. */
3
+ type DocType<C extends string> = C extends keyof ZenithCollections ? ZenithCollections[C] : any;
4
+ export interface ZenithClientOptions {
5
+ url: string;
6
+ apiKey?: string;
7
+ siteId?: string;
8
+ /** SWR cache TTL in ms. default 30_000 (30s). set 0 to disable. */
9
+ cacheTtl?: number;
10
+ }
11
+ export interface FetchOptions extends RequestInit {
12
+ locale?: string;
13
+ depth?: number;
14
+ drafts?: boolean;
15
+ populate?: string[] | string;
16
+ select?: string[] | string;
17
+ /** Override the global cache TTL for this request. 0 = bypass cache. */
18
+ cacheTtl?: number;
19
+ /** Tag-based cache invalidation key for this request */
20
+ cacheTag?: string;
21
+ }
22
+ export interface FindOptions extends FetchOptions {
23
+ where?: Record<string, any>;
24
+ sort?: string;
25
+ limit?: number;
26
+ page?: number;
27
+ }
28
+ /** Structured error thrown by all ZenithClient methods. */
29
+ export declare class ZenithAPIError extends Error {
30
+ readonly status: number;
31
+ readonly code?: string;
32
+ readonly isNetworkError: boolean;
33
+ readonly isParseError: boolean;
34
+ constructor(opts: {
35
+ message: string;
36
+ status: number;
37
+ code?: string;
38
+ isNetworkError?: boolean;
39
+ isParseError?: boolean;
40
+ });
41
+ }
42
+ interface BatchRequest {
43
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
44
+ path: string;
45
+ body?: unknown;
46
+ headers?: Record<string, string>;
47
+ }
48
+ /**
49
+ * Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
50
+ * Zero external dependencies — uses native browser fetch.
51
+ */
52
+ export declare class ZenithClient {
53
+ private url;
54
+ private apiKey?;
55
+ private siteId?;
56
+ private cache;
57
+ constructor(options: ZenithClientOptions);
58
+ /** Flush all cached responses. */
59
+ flushCache(): void;
60
+ /** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
61
+ setSiteId(siteId?: string): void;
62
+ /** Invalidate cache entries matching a tag. */
63
+ invalidateCache(tag: string): void;
64
+ private buildQueryString;
65
+ private flattenWhereParams;
66
+ private buildHeaders;
67
+ private fetchAPI;
68
+ private cacheKey;
69
+ /**
70
+ * Find multiple documents in a collection.
71
+ * Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
72
+ */
73
+ find<C extends string>(collection: C, options?: FindOptions): Promise<{
74
+ docs: DocType<C>[];
75
+ totalDocs: number;
76
+ totalPages: number;
77
+ page: number;
78
+ }>;
79
+ /**
80
+ * Find a single document by ID.
81
+ * Uses SWR cache by default.
82
+ */
83
+ findById<C extends string>(collection: C, id: string, options?: FetchOptions): Promise<DocType<C>>;
84
+ /**
85
+ * Fetch a singleton configuration.
86
+ */
87
+ findGlobal<T = any>(slug: string, options?: FetchOptions): Promise<T>;
88
+ /**
89
+ * Create a new document in a collection.
90
+ * Invalidates cache for the target collection.
91
+ */
92
+ create<C extends string>(collection: C, payload: Partial<DocType<C>>, options?: FetchOptions): Promise<DocType<C>>;
93
+ /**
94
+ * Update an existing document.
95
+ * Invalidates cache for the target collection.
96
+ */
97
+ update<C extends string>(collection: C, id: string, payload: Partial<DocType<C>>, options?: FetchOptions): Promise<DocType<C>>;
98
+ /**
99
+ * Delete a document.
100
+ * Invalidates cache for the target collection.
101
+ */
102
+ delete<C extends string>(collection: C, id: string, options?: FetchOptions): Promise<DocType<C>>;
103
+ /**
104
+ * Count documents matching a filter.
105
+ * Uses SWR cache.
106
+ */
107
+ count<C extends string>(collection: C, filter?: Record<string, any>): Promise<number>;
108
+ /**
109
+ * Run an aggregation pipeline on a collection.
110
+ * Sends the pipeline to a dedicated endpoint.
111
+ */
112
+ aggregate<C extends string>(collection: C, pipeline: Record<string, unknown>[]): Promise<unknown[]>;
113
+ /**
114
+ * Execute multiple API calls in a single round-trip (parallel).
115
+ * All requests fire concurrently; waits for all to settle.
116
+ * Returns an array of results in the same order as the input requests.
117
+ *
118
+ * @example
119
+ * const [posts, authors] = await client.batch([
120
+ * { method: 'GET', path: '/api/v1/posts?limit=10' },
121
+ * { method: 'GET', path: '/api/v1/authors?limit=5' },
122
+ * ])
123
+ */
124
+ batch(requests: BatchRequest[]): Promise<any[]>;
125
+ /**
126
+ * Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
127
+ *
128
+ * @param file - File or Blob from <input type="file"> or FileReader
129
+ * @param metadata - Optional alt text, focal point, folder
130
+ */
131
+ upload(file: File | Blob, metadata?: {
132
+ alt?: string;
133
+ focalPoint?: {
134
+ x: number;
135
+ y: number;
136
+ };
137
+ folder?: string;
138
+ }): Promise<any>;
139
+ /**
140
+ * Upload multiple files in parallel.
141
+ * Uses Promise.all for concurrent uploads.
142
+ */
143
+ uploadMany(files: (File | Blob)[], metadata?: Parameters<typeof this.upload>[1]): Promise<any[]>;
144
+ }
145
+ export declare function createClient(options: ZenithClientOptions): ZenithClient;
146
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Stale-While-Revalidate cache.
3
+ * Returns stale data immediately, then revalidates in the background.
4
+ * Thread-safe for concurrent requests to the same key.
5
+ */
6
+ class SWRCache {
7
+ store = new Map();
8
+ pending = new Map();
9
+ defaultTtl;
10
+ constructor(defaultTtl = 30_000) {
11
+ this.defaultTtl = defaultTtl;
12
+ }
13
+ get(key) {
14
+ const entry = this.store.get(key);
15
+ if (!entry)
16
+ return null;
17
+ const age = Date.now() - entry.timestamp;
18
+ if (age > this.defaultTtl) {
19
+ return { data: entry.data, stale: true };
20
+ }
21
+ return { data: entry.data, stale: false };
22
+ }
23
+ set(key, data, etag) {
24
+ this.store.set(key, { data, timestamp: Date.now(), etag });
25
+ }
26
+ invalidate(key) {
27
+ this.store.delete(key);
28
+ }
29
+ invalidateTag(tag) {
30
+ // Invalidate all cache entries whose key contains the tag
31
+ for (const key of this.store.keys()) {
32
+ if (key.includes(tag))
33
+ this.store.delete(key);
34
+ }
35
+ }
36
+ flush() {
37
+ this.store.clear();
38
+ for (const { controllers } of this.pending.values()) {
39
+ controllers.forEach((c) => c.abort());
40
+ }
41
+ this.pending.clear();
42
+ }
43
+ }
44
+ // ── Error Handling ────────────────────────────────────────────────────────────
45
+ /** Structured error thrown by all ZenithClient methods. */
46
+ export class ZenithAPIError extends Error {
47
+ status;
48
+ code;
49
+ isNetworkError;
50
+ isParseError;
51
+ constructor(opts) {
52
+ super(opts.message);
53
+ this.name = 'ZenithAPIError';
54
+ this.status = opts.status;
55
+ this.code = opts.code;
56
+ this.isNetworkError = opts.isNetworkError ?? false;
57
+ this.isParseError = opts.isParseError ?? false;
58
+ }
59
+ }
60
+ // ── Main Client ──────────────────────────────────────────────────────────────
61
+ /**
62
+ * Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
63
+ * Zero external dependencies — uses native browser fetch.
64
+ */
65
+ export class ZenithClient {
66
+ url;
67
+ apiKey;
68
+ siteId;
69
+ cache;
70
+ constructor(options) {
71
+ this.url = options.url.replace(/\/$/, '');
72
+ this.apiKey = options.apiKey;
73
+ this.siteId = options.siteId;
74
+ this.cache = new SWRCache(options.cacheTtl ?? 30_000);
75
+ }
76
+ // ── Cache control ───────────────────────────────────────────────────────────
77
+ /** Flush all cached responses. */
78
+ flushCache() {
79
+ this.cache.flush();
80
+ }
81
+ /** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
82
+ setSiteId(siteId) {
83
+ this.siteId = siteId;
84
+ this.flushCache();
85
+ }
86
+ /** Invalidate cache entries matching a tag. */
87
+ invalidateCache(tag) {
88
+ this.cache.invalidateTag(tag);
89
+ }
90
+ buildQueryString(options) {
91
+ const params = new URLSearchParams();
92
+ if (options.locale)
93
+ params.append('locale', options.locale);
94
+ if (options.depth !== undefined)
95
+ params.append('depth', String(options.depth));
96
+ if (options.drafts)
97
+ params.append('drafts', 'true');
98
+ if (options.sort)
99
+ params.append('sort', options.sort);
100
+ if (options.limit !== undefined)
101
+ params.append('limit', String(options.limit));
102
+ if (options.page !== undefined)
103
+ params.append('page', String(options.page));
104
+ if (options.populate) {
105
+ const popStr = Array.isArray(options.populate) ? options.populate.join(',') : options.populate;
106
+ params.append('populate', popStr);
107
+ }
108
+ if (options.select) {
109
+ const selStr = Array.isArray(options.select) ? options.select.join(',') : options.select;
110
+ params.append('select', selStr);
111
+ }
112
+ if (options.where) {
113
+ this.flattenWhereParams(options.where, 'where').forEach((value, key) => {
114
+ params.append(key, value);
115
+ });
116
+ }
117
+ const str = params.toString();
118
+ return str ? `?${str}` : '';
119
+ }
120
+ flattenWhereParams(obj, prefix) {
121
+ const map = new Map();
122
+ for (const [key, value] of Object.entries(obj)) {
123
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
124
+ const nested = this.flattenWhereParams(value, `${prefix}[${key}]`);
125
+ nested.forEach((v, k) => map.set(k, v));
126
+ }
127
+ else {
128
+ map.set(`${prefix}[${key}]`, String(value));
129
+ }
130
+ }
131
+ return map;
132
+ }
133
+ buildHeaders(extra) {
134
+ const headers = new Headers(extra);
135
+ headers.set('Content-Type', 'application/json');
136
+ if (this.apiKey)
137
+ headers.set('Authorization', `Bearer ${this.apiKey}`);
138
+ if (this.siteId)
139
+ headers.set('X-Zenith-Site-Id', this.siteId);
140
+ return headers;
141
+ }
142
+ async fetchAPI(path, options = {}, cacheKey) {
143
+ const headers = this.buildHeaders(options.headers);
144
+ // ── SWR: serve stale data immediately, revalidate in background ──────────
145
+ const useCache = options.cacheTtl !== 0 && cacheKey;
146
+ const entry = useCache ? this.cache.get(cacheKey) : null;
147
+ if (entry && useCache) {
148
+ const revalidate = entry.stale && options.cacheTtl !== 0;
149
+ if (revalidate) {
150
+ // Deduplicate: if a revalidation for this key is already in-flight, skip
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
+ const pending = this.cache['pending'];
153
+ if (pending?.has(cacheKey)) {
154
+ // revalidation already running for this key — don't spawn another
155
+ }
156
+ else {
157
+ const ctrl = new AbortController();
158
+ const revalidatePromise = fetch(`${this.url}${path}`, {
159
+ ...options,
160
+ headers,
161
+ signal: ctrl.signal,
162
+ })
163
+ .then((res) => {
164
+ if (res.ok)
165
+ return res.json();
166
+ throw new ZenithAPIError({ message: `HTTP ${res.status}`, status: res.status });
167
+ })
168
+ .then((data) => {
169
+ this.cache.set(cacheKey, data);
170
+ pending?.delete(cacheKey);
171
+ })
172
+ .catch(() => {
173
+ // SWR revalidation failures are silent — stale data is acceptable
174
+ pending?.delete(cacheKey);
175
+ });
176
+ if (pending) {
177
+ pending.set(cacheKey, { promise: revalidatePromise, controllers: [ctrl] });
178
+ }
179
+ }
180
+ }
181
+ return entry.data;
182
+ }
183
+ // No cache or cache disabled — fetch normally
184
+ let response;
185
+ try {
186
+ response = await fetch(`${this.url}${path}`, { ...options, headers });
187
+ }
188
+ catch (networkErr) {
189
+ throw new ZenithAPIError({
190
+ message: networkErr instanceof Error ? networkErr.message : 'Network error',
191
+ status: 0,
192
+ isNetworkError: true,
193
+ });
194
+ }
195
+ let data;
196
+ try {
197
+ data = await response.json();
198
+ }
199
+ catch {
200
+ throw new ZenithAPIError({
201
+ message: `Invalid JSON response from server (HTTP ${response.status})`,
202
+ status: response.status,
203
+ isParseError: true,
204
+ });
205
+ }
206
+ if (!response.ok) {
207
+ throw new ZenithAPIError({
208
+ message: data?.message || `Zenith API error: ${response.status} ${response.statusText}`,
209
+ status: response.status,
210
+ });
211
+ }
212
+ if (useCache) {
213
+ this.cache.set(cacheKey, data);
214
+ }
215
+ return data;
216
+ }
217
+ cacheKey(collection, path) {
218
+ return `${collection}:${path}`;
219
+ }
220
+ // ── Content API ─────────────────────────────────────────────────────────────
221
+ /**
222
+ * Find multiple documents in a collection.
223
+ * Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
224
+ */
225
+ async find(collection, options = {}) {
226
+ const qs = this.buildQueryString(options);
227
+ const cacheKey = (options.cacheTtl !== 0)
228
+ ? (options.cacheTag
229
+ ? this.cacheKey(collection, `/api/v1/${collection}${qs}`)
230
+ : `/api/v1/${collection}${qs}`)
231
+ : undefined;
232
+ const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, { method: 'GET', ...options }, cacheKey);
233
+ const docs = Array.isArray(data.docs)
234
+ ? data.docs
235
+ : Array.isArray(data.data)
236
+ ? data.data
237
+ : data.data?.docs || [];
238
+ const totalDocs = data.totalDocs ?? data.meta?.pagination?.total ?? docs.length;
239
+ const totalPages = data.totalPages ?? data.meta?.pagination?.totalPages ?? 1;
240
+ const page = data.page ?? data.meta?.pagination?.page ?? 1;
241
+ return { docs, totalDocs, totalPages, page, data: docs, ...data };
242
+ }
243
+ /**
244
+ * Find a single document by ID.
245
+ * Uses SWR cache by default.
246
+ */
247
+ async findById(collection, id, options = {}) {
248
+ const qs = this.buildQueryString(options);
249
+ const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, { method: 'GET', ...options }, `/api/v1/${collection}/${id}${qs}`);
250
+ return data.data?.document || data.data || data;
251
+ }
252
+ /**
253
+ * Fetch a singleton configuration.
254
+ */
255
+ async findGlobal(slug, options = {}) {
256
+ const qs = this.buildQueryString(options);
257
+ const data = await this.fetchAPI(`/api/v1/globals/${slug}${qs}`, { method: 'GET', ...options }, `/api/v1/globals/${slug}${qs}`);
258
+ return data.data?.document || data.data || data;
259
+ }
260
+ /**
261
+ * Create a new document in a collection.
262
+ * Invalidates cache for the target collection.
263
+ */
264
+ async create(collection, payload, options = {}) {
265
+ const qs = this.buildQueryString(options);
266
+ const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, {
267
+ method: 'POST',
268
+ body: JSON.stringify(payload),
269
+ ...options,
270
+ cacheTtl: 0, // writes never use cache
271
+ });
272
+ this.cache.invalidateTag(collection); // optimistic invalidation
273
+ return data.data || data;
274
+ }
275
+ /**
276
+ * Update an existing document.
277
+ * Invalidates cache for the target collection.
278
+ */
279
+ async update(collection, id, payload, options = {}) {
280
+ const qs = this.buildQueryString(options);
281
+ const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
282
+ method: 'PATCH',
283
+ body: JSON.stringify(payload),
284
+ ...options,
285
+ cacheTtl: 0,
286
+ });
287
+ this.cache.invalidateTag(collection);
288
+ return data.data?.document || data.data || data;
289
+ }
290
+ /**
291
+ * Delete a document.
292
+ * Invalidates cache for the target collection.
293
+ */
294
+ async delete(collection, id, options = {}) {
295
+ const qs = this.buildQueryString(options);
296
+ const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
297
+ method: 'DELETE',
298
+ ...options,
299
+ cacheTtl: 0,
300
+ });
301
+ this.cache.invalidateTag(collection);
302
+ return data.data?.document || data.data || data;
303
+ }
304
+ // ── Aggregation & Counts ────────────────────────────────────────────────────
305
+ /**
306
+ * Count documents matching a filter.
307
+ * Uses SWR cache.
308
+ */
309
+ async count(collection, filter) {
310
+ const params = new URLSearchParams();
311
+ if (filter) {
312
+ Object.entries(filter).forEach(([k, v]) => {
313
+ if (v !== undefined && v !== null)
314
+ params.append(`where[${k}]`, String(v));
315
+ });
316
+ }
317
+ const paramStr = params.toString();
318
+ const qs = paramStr ? `?${paramStr}` : '';
319
+ const data = await this.fetchAPI(`/api/v1/${collection}/count${qs}`, { method: 'GET', cacheTtl: 0 });
320
+ return typeof data.data?.count === 'number' ? data.data.count : (data.count ?? 0);
321
+ }
322
+ /**
323
+ * Run an aggregation pipeline on a collection.
324
+ * Sends the pipeline to a dedicated endpoint.
325
+ */
326
+ async aggregate(collection, pipeline) {
327
+ const data = await this.fetchAPI(`/api/v1/${collection}/aggregate`, {
328
+ method: 'POST',
329
+ body: JSON.stringify({ pipeline }),
330
+ cacheTtl: 0,
331
+ });
332
+ return data.data?.results ?? data.results ?? data;
333
+ }
334
+ // ── Batch Operations ────────────────────────────────────────────────────────
335
+ /**
336
+ * Execute multiple API calls in a single round-trip (parallel).
337
+ * All requests fire concurrently; waits for all to settle.
338
+ * Returns an array of results in the same order as the input requests.
339
+ *
340
+ * @example
341
+ * const [posts, authors] = await client.batch([
342
+ * { method: 'GET', path: '/api/v1/posts?limit=10' },
343
+ * { method: 'GET', path: '/api/v1/authors?limit=5' },
344
+ * ])
345
+ */
346
+ async batch(requests) {
347
+ const results = await Promise.all(requests.map(async (req) => {
348
+ const headers = this.buildHeaders(req.headers);
349
+ try {
350
+ const response = await fetch(`${this.url}${req.path}`, {
351
+ method: req.method || 'GET',
352
+ headers,
353
+ body: req.body ? JSON.stringify(req.body) : undefined,
354
+ });
355
+ const data = await response.json().catch(() => null);
356
+ if (!response.ok) {
357
+ throw new Error(data?.message || `Batch item failed: ${response.status}`);
358
+ }
359
+ return data.data ?? data;
360
+ }
361
+ catch (err) {
362
+ // Propagate errors so Promise.allSettled-like behavior is available via .catch
363
+ throw err;
364
+ }
365
+ }));
366
+ return results;
367
+ }
368
+ // ── File Upload ─────────────────────────────────────────────────────────────
369
+ /**
370
+ * Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
371
+ *
372
+ * @param file - File or Blob from <input type="file"> or FileReader
373
+ * @param metadata - Optional alt text, focal point, folder
374
+ */
375
+ async upload(file, metadata) {
376
+ const formData = new FormData();
377
+ const fileName = 'name' in file ? file.name : 'file';
378
+ formData.append('file', file, fileName);
379
+ // Attach focal point as JSON string (multer parses it server-side)
380
+ if (metadata?.focalPoint) {
381
+ formData.append('focalPoint', JSON.stringify(metadata.focalPoint));
382
+ }
383
+ if (metadata?.alt) {
384
+ formData.append('alt', metadata.alt);
385
+ }
386
+ if (metadata?.folder) {
387
+ formData.append('folder', metadata.folder);
388
+ }
389
+ const headers = this.buildHeaders();
390
+ // Remove Content-Type so fetch sets the correct multipart boundary
391
+ headers.delete('Content-Type');
392
+ const response = await fetch(`${this.url}/api/v1/upload`, {
393
+ method: 'POST',
394
+ headers,
395
+ body: formData,
396
+ });
397
+ const data = await response.json().catch(() => null);
398
+ if (!response.ok) {
399
+ throw new Error(data?.message || `Upload failed: ${response.status}`);
400
+ }
401
+ return data.data ?? data;
402
+ }
403
+ /**
404
+ * Upload multiple files in parallel.
405
+ * Uses Promise.all for concurrent uploads.
406
+ */
407
+ async uploadMany(files, metadata) {
408
+ return Promise.all(files.map((file) => this.upload(file, metadata)));
409
+ }
410
+ }
411
+ export function createClient(options) {
412
+ return new ZenithClient(options);
413
+ }
package/package.json CHANGED
@@ -1,29 +1,33 @@
1
- {
2
- "name": "@zenith-open/zenithcms-sdk",
3
- "version": "1.0.0",
4
- "publishConfig": {
5
- "access": "public"
6
- },
7
- "description": "Lightweight JavaScript client for Zenith CMS",
8
- "type": "module",
9
- "main": "./dist/index.js",
10
- "module": "./dist/index.js",
11
- "types": "./dist/index.d.ts",
12
- "exports": {
13
- ".": {
14
- "types": "./dist/index.d.ts",
15
- "import": "./dist/index.js",
16
- "default": "./dist/index.js"
17
- }
18
- },
19
- "scripts": {
20
- "build": "tsc",
21
- "test": "vitest run",
22
- "lint": "echo 'Lint bypassed for sdk'"
23
- },
24
- "license": "MIT",
25
- "devDependencies": {
26
- "@zenith-open/zenithcms-types": "workspace:*",
27
- "vitest": "^1.6.0"
28
- }
29
- }
1
+ {
2
+ "name": "@zenith-open/zenithcms-sdk",
3
+ "version": "1.0.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Lightweight JavaScript client for Zenith CMS",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "@zenith-open/zenithcms-types": "^1.0.1",
22
+ "vitest": "^1.6.0"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "test": "vitest run",
31
+ "lint": "echo 'Lint bypassed for sdk'"
32
+ }
33
+ }
package/src/index.test.ts DELETED
@@ -1,117 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { ZenithClient } from './index'
3
-
4
- // Minimal fetch mock — intercepts calls and returns JSON
5
- function makeClient() {
6
- return new ZenithClient({ url: 'http://localhost:3000' })
7
- }
8
-
9
- beforeEach(() => {
10
- global.fetch = vi.fn()
11
- })
12
-
13
- describe('ZenithClient — SWR cache', () => {
14
- it('serves stale data immediately and revalidates in background', async () => {
15
- const client = makeClient()
16
- const mockData = { data: { docs: [{ _id: '1', title: 'Cached Post' }] } }
17
-
18
- // First request — pre-populate cache with stale data
19
- ;(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
20
- ok: true,
21
- json: () => Promise.resolve(mockData),
22
- })
23
-
24
- const result1 = await client.find('posts', { limit: 5, cacheTtl: 30_000 })
25
- expect(result1.docs[0].title).toBe('Cached Post')
26
-
27
- // Second request should return cached data immediately without waiting
28
- const start = Date.now()
29
- const result2 = await client.find('posts', { limit: 5 })
30
- const elapsed = Date.now() - start
31
- // With cache hit and SWR revalidation, this should be near-instant
32
- expect(elapsed).toBeLessThan(50)
33
- expect(result2.docs[0].title).toBe('Cached Post')
34
- })
35
-
36
- it('bypasses cache when cacheTtl is 0', async () => {
37
- const client = makeClient()
38
- ;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
39
- ok: true,
40
- json: () => Promise.resolve({ data: { docs: [] } }),
41
- })
42
-
43
- // Should call fetch both times
44
- await client.find('posts', { cacheTtl: 0 })
45
- await client.find('posts', { cacheTtl: 0 })
46
-
47
- expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2)
48
- })
49
- })
50
-
51
- describe('ZenithClient — batch', () => {
52
- it('executes multiple requests in parallel', async () => {
53
- const client = makeClient()
54
- ;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
55
- ok: true,
56
- json: () => Promise.resolve({ data: { posts: [] } }),
57
- })
58
-
59
- await client.batch([
60
- { method: 'GET', path: '/api/v1/posts' },
61
- { method: 'GET', path: '/api/v1/authors' },
62
- ])
63
-
64
- expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
65
- })
66
- })
67
-
68
- describe('ZenithClient — upload', () => {
69
- it('sends FormData for file uploads and omits Content-Type header', async () => {
70
- const client = makeClient()
71
- ;(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
72
- ok: true,
73
- json: () => Promise.resolve({ data: { _id: '123', url: 'http://cdn/img.jpg' } }),
74
- })
75
-
76
- const file = new File(['hello'], 'test.jpg', { type: 'image/jpeg' })
77
- await client.upload(file, { alt: 'Test alt', focalPoint: { x: 50, y: 50 } })
78
-
79
- const [url, options] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0]
80
- expect(url).toContain('/api/v1/upload')
81
- expect(options.headers.get('Content-Type')).toBeNull() // fetch auto-sets multipart
82
- expect(options.method).toBe('POST')
83
- })
84
- })
85
-
86
- describe('ZenithClient — site switching', () => {
87
- it('updates siteId and flushes cache when setSiteId is called', async () => {
88
- const client = makeClient()
89
- const mockData1 = { data: { docs: [{ _id: '1', title: 'Post Site A' }] } }
90
- const mockData2 = { data: { docs: [{ _id: '2', title: 'Post Site B' }] } }
91
-
92
- ;(fetch as ReturnType<typeof vi.fn>)
93
- .mockResolvedValueOnce({
94
- ok: true,
95
- json: () => Promise.resolve(mockData1),
96
- })
97
- .mockResolvedValueOnce({
98
- ok: true,
99
- json: () => Promise.resolve(mockData2),
100
- })
101
-
102
- // Fetch on default site ID (empty)
103
- const res1 = await client.find('posts', { limit: 5 })
104
- expect(res1.docs[0].title).toBe('Post Site A')
105
-
106
- // Change site ID using setSiteId
107
- client.setSiteId('site-b')
108
-
109
- // Fetch again — cache should be flushed, performing a new fetch with headers
110
- const res2 = await client.find('posts', { limit: 5 })
111
- expect(res2.docs[0].title).toBe('Post Site B')
112
-
113
- // Verify correct header was sent in the second request
114
- const lastCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[1]
115
- expect(lastCall[1].headers.get('X-Zenith-Site-Id')).toBe('site-b')
116
- })
117
- })
package/src/index.ts DELETED
@@ -1,556 +0,0 @@
1
- import type { ZenithCollections } from '@zenith-open/zenithcms-types'
2
-
3
- /** Resolve a collection name string to its document type, falling back to any for unknown collections. */
4
- type DocType<C extends string> = C extends keyof ZenithCollections ? ZenithCollections[C] : any
5
-
6
- export interface ZenithClientOptions {
7
- url: string
8
- apiKey?: string
9
- siteId?: string
10
- /** SWR cache TTL in ms. default 30_000 (30s). set 0 to disable. */
11
- cacheTtl?: number
12
- }
13
-
14
- export interface FetchOptions extends RequestInit {
15
- locale?: string
16
- depth?: number
17
- drafts?: boolean
18
- populate?: string[] | string
19
- select?: string[] | string
20
- /** Override the global cache TTL for this request. 0 = bypass cache. */
21
- cacheTtl?: number
22
- /** Tag-based cache invalidation key for this request */
23
- cacheTag?: string
24
- }
25
-
26
- export interface FindOptions extends FetchOptions {
27
- where?: Record<string, any>
28
- sort?: string
29
- limit?: number
30
- page?: number
31
- }
32
-
33
- // ── SWR Cache ────────────────────────────────────────────────────────────────
34
-
35
- interface CacheEntry<T> {
36
- data: T
37
- timestamp: number
38
- etag?: string
39
- }
40
-
41
- interface PendingRequest {
42
- promise: Promise<unknown>
43
- controllers: AbortController[]
44
- }
45
-
46
- /**
47
- * Stale-While-Revalidate cache.
48
- * Returns stale data immediately, then revalidates in the background.
49
- * Thread-safe for concurrent requests to the same key.
50
- */
51
- class SWRCache {
52
- private store = new Map<string, CacheEntry<unknown>>()
53
- private pending = new Map<string, PendingRequest>()
54
- private defaultTtl: number
55
-
56
- constructor(defaultTtl = 30_000) {
57
- this.defaultTtl = defaultTtl
58
- }
59
-
60
- get<T>(key: string): { data: T; stale: boolean } | null {
61
- const entry = this.store.get(key) as CacheEntry<T> | undefined
62
- if (!entry) return null
63
- const age = Date.now() - entry.timestamp
64
- if (age > this.defaultTtl) {
65
- return { data: entry.data, stale: true }
66
- }
67
- return { data: entry.data, stale: false }
68
- }
69
-
70
- set<T>(key: string, data: T, etag?: string): void {
71
- this.store.set(key, { data, timestamp: Date.now(), etag })
72
- }
73
-
74
- invalidate(key: string): void {
75
- this.store.delete(key)
76
- }
77
-
78
- invalidateTag(tag: string): void {
79
- // Invalidate all cache entries whose key contains the tag
80
- for (const key of this.store.keys()) {
81
- if (key.includes(tag)) this.store.delete(key)
82
- }
83
- }
84
-
85
- flush(): void {
86
- this.store.clear()
87
- for (const { controllers } of this.pending.values()) {
88
- controllers.forEach((c) => c.abort())
89
- }
90
- this.pending.clear()
91
- }
92
- }
93
-
94
- // ── Error Handling ────────────────────────────────────────────────────────────
95
-
96
- /** Structured error thrown by all ZenithClient methods. */
97
- export class ZenithAPIError extends Error {
98
- readonly status: number
99
- readonly code?: string
100
- readonly isNetworkError: boolean
101
- readonly isParseError: boolean
102
-
103
- constructor(opts: {
104
- message: string
105
- status: number
106
- code?: string
107
- isNetworkError?: boolean
108
- isParseError?: boolean
109
- }) {
110
- super(opts.message)
111
- this.name = 'ZenithAPIError'
112
- this.status = opts.status
113
- this.code = opts.code
114
- this.isNetworkError = opts.isNetworkError ?? false
115
- this.isParseError = opts.isParseError ?? false
116
- }
117
- }
118
-
119
- // ── Batch Operation Request Descriptor ──────────────────────────────────────
120
-
121
- interface BatchRequest {
122
- method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
123
- path: string
124
- body?: unknown
125
- headers?: Record<string, string>
126
- }
127
-
128
- // ── Main Client ──────────────────────────────────────────────────────────────
129
-
130
- /**
131
- * Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
132
- * Zero external dependencies — uses native browser fetch.
133
- */
134
- export class ZenithClient {
135
- private url: string
136
- private apiKey?: string
137
- private siteId?: string
138
- private cache: SWRCache
139
-
140
- constructor(options: ZenithClientOptions) {
141
- this.url = options.url.replace(/\/$/, '')
142
- this.apiKey = options.apiKey
143
- this.siteId = options.siteId
144
- this.cache = new SWRCache(options.cacheTtl ?? 30_000)
145
- }
146
-
147
- // ── Cache control ───────────────────────────────────────────────────────────
148
-
149
- /** Flush all cached responses. */
150
- flushCache(): void {
151
- this.cache.flush()
152
- }
153
-
154
- /** Set or update the active site ID. Also flushes the cache to prevent cross-tenant cached content leaks. */
155
- setSiteId(siteId?: string): void {
156
- this.siteId = siteId
157
- this.flushCache()
158
- }
159
-
160
- /** Invalidate cache entries matching a tag. */
161
- invalidateCache(tag: string): void {
162
- this.cache.invalidateTag(tag)
163
- }
164
-
165
- private buildQueryString(options: FindOptions): string {
166
- const params = new URLSearchParams()
167
-
168
- if (options.locale) params.append('locale', options.locale)
169
- if (options.depth !== undefined) params.append('depth', String(options.depth))
170
- if (options.drafts) params.append('drafts', 'true')
171
- if (options.sort) params.append('sort', options.sort)
172
- if (options.limit !== undefined) params.append('limit', String(options.limit))
173
- if (options.page !== undefined) params.append('page', String(options.page))
174
- if (options.populate) {
175
- const popStr = Array.isArray(options.populate) ? options.populate.join(',') : options.populate
176
- params.append('populate', popStr)
177
- }
178
- if (options.select) {
179
- const selStr = Array.isArray(options.select) ? options.select.join(',') : options.select
180
- params.append('select', selStr)
181
- }
182
-
183
- if (options.where) {
184
- this.flattenWhereParams(options.where, 'where').forEach((value, key) => {
185
- params.append(key, value)
186
- })
187
- }
188
-
189
- const str = params.toString()
190
- return str ? `?${str}` : ''
191
- }
192
-
193
- private flattenWhereParams(obj: Record<string, any>, prefix: string): Map<string, string> {
194
- const map = new Map<string, string>()
195
- for (const [key, value] of Object.entries(obj)) {
196
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
197
- const nested = this.flattenWhereParams(value, `${prefix}[${key}]`)
198
- nested.forEach((v, k) => map.set(k, v))
199
- } else {
200
- map.set(`${prefix}[${key}]`, String(value))
201
- }
202
- }
203
- return map
204
- }
205
-
206
- private buildHeaders(extra?: Record<string, string>): Headers {
207
- const headers = new Headers(extra)
208
- headers.set('Content-Type', 'application/json')
209
- if (this.apiKey) headers.set('Authorization', `Bearer ${this.apiKey}`)
210
- if (this.siteId) headers.set('X-Zenith-Site-Id', this.siteId)
211
- return headers
212
- }
213
-
214
- private async fetchAPI(
215
- path: string,
216
- options: FetchOptions = {},
217
- cacheKey?: string
218
- ): Promise<any> {
219
- const headers = this.buildHeaders(options.headers as Record<string, string>)
220
-
221
- // ── SWR: serve stale data immediately, revalidate in background ──────────
222
- const useCache = options.cacheTtl !== 0 && cacheKey
223
- const entry = useCache ? this.cache.get<unknown>(cacheKey) : null
224
-
225
- if (entry && useCache) {
226
- const revalidate = entry.stale && options.cacheTtl !== 0
227
-
228
- if (revalidate) {
229
- // Deduplicate: if a revalidation for this key is already in-flight, skip
230
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
231
- const pending = (this.cache as any)['pending'] as Map<string, { promise: Promise<unknown>; controllers: AbortController[] }> | undefined
232
- if (pending?.has(cacheKey!)) {
233
- // revalidation already running for this key — don't spawn another
234
- } else {
235
- const ctrl = new AbortController()
236
- const revalidatePromise = fetch(`${this.url}${path}`, {
237
- ...options,
238
- headers,
239
- signal: ctrl.signal,
240
- })
241
- .then((res) => {
242
- if (res.ok) return res.json()
243
- throw new ZenithAPIError({ message: `HTTP ${res.status}`, status: res.status })
244
- })
245
- .then((data) => {
246
- this.cache.set(cacheKey!, data)
247
- pending?.delete(cacheKey!)
248
- })
249
- .catch(() => {
250
- // SWR revalidation failures are silent — stale data is acceptable
251
- pending?.delete(cacheKey!)
252
- })
253
-
254
- if (pending) {
255
- pending.set(cacheKey!, { promise: revalidatePromise as Promise<unknown>, controllers: [ctrl] })
256
- }
257
- }
258
- }
259
-
260
- return entry.data as any
261
- }
262
-
263
- // No cache or cache disabled — fetch normally
264
- let response: Response
265
- try {
266
- response = await fetch(`${this.url}${path}`, { ...options, headers })
267
- } catch (networkErr) {
268
- throw new ZenithAPIError({
269
- message: networkErr instanceof Error ? networkErr.message : 'Network error',
270
- status: 0,
271
- isNetworkError: true,
272
- })
273
- }
274
-
275
- let data: any
276
- try {
277
- data = await response.json()
278
- } catch {
279
- throw new ZenithAPIError({
280
- message: `Invalid JSON response from server (HTTP ${response.status})`,
281
- status: response.status,
282
- isParseError: true,
283
- })
284
- }
285
-
286
- if (!response.ok) {
287
- throw new ZenithAPIError({
288
- message: data?.message || `Zenith API error: ${response.status} ${response.statusText}`,
289
- status: response.status,
290
- })
291
- }
292
-
293
- if (useCache) {
294
- this.cache.set(cacheKey!, data)
295
- }
296
-
297
- return data
298
- }
299
-
300
- private cacheKey(collection: string, path: string): string {
301
- return `${collection}:${path}`
302
- }
303
-
304
- // ── Content API ─────────────────────────────────────────────────────────────
305
-
306
- /**
307
- * Find multiple documents in a collection.
308
- * Uses SWR cache by default (30s TTL). Bypass with `cacheTtl: 0`.
309
- */
310
- async find<C extends string>(
311
- collection: C,
312
- options: FindOptions = {}
313
- ): Promise<{ docs: DocType<C>[]; totalDocs: number; totalPages: number; page: number }> {
314
- const qs = this.buildQueryString(options)
315
- const cacheKey = (options.cacheTtl !== 0)
316
- ? (options.cacheTag
317
- ? this.cacheKey(collection, `/api/v1/${collection}${qs}`)
318
- : `/api/v1/${collection}${qs}`)
319
- : undefined
320
-
321
- const data = await this.fetchAPI(
322
- `/api/v1/${collection}${qs}`,
323
- { method: 'GET', ...options },
324
- cacheKey
325
- )
326
-
327
- const docs = Array.isArray(data.docs)
328
- ? data.docs
329
- : Array.isArray(data.data)
330
- ? data.data
331
- : data.data?.docs || []
332
-
333
- const totalDocs = data.totalDocs ?? data.meta?.pagination?.total ?? docs.length
334
- const totalPages = data.totalPages ?? data.meta?.pagination?.totalPages ?? 1
335
- const page = data.page ?? data.meta?.pagination?.page ?? 1
336
-
337
- return { docs, totalDocs, totalPages, page, data: docs, ...data } as any
338
- }
339
-
340
- /**
341
- * Find a single document by ID.
342
- * Uses SWR cache by default.
343
- */
344
- async findById<C extends string>(collection: C, id: string, options: FetchOptions = {}): Promise<DocType<C>> {
345
- const qs = this.buildQueryString(options)
346
- const data = await this.fetchAPI(
347
- `/api/v1/${collection}/${id}${qs}`,
348
- { method: 'GET', ...options },
349
- `/api/v1/${collection}/${id}${qs}`
350
- )
351
- return data.data?.document || data.data || data
352
- }
353
-
354
- /**
355
- * Fetch a singleton configuration.
356
- */
357
- async findGlobal<T = any>(slug: string, options: FetchOptions = {}): Promise<T> {
358
- const qs = this.buildQueryString(options)
359
- const data = await this.fetchAPI(
360
- `/api/v1/globals/${slug}${qs}`,
361
- { method: 'GET', ...options },
362
- `/api/v1/globals/${slug}${qs}`
363
- )
364
- return data.data?.document || data.data || data
365
- }
366
-
367
- /**
368
- * Create a new document in a collection.
369
- * Invalidates cache for the target collection.
370
- */
371
- async create<C extends string>(collection: C, payload: Partial<DocType<C>>, options: FetchOptions = {}): Promise<DocType<C>> {
372
- const qs = this.buildQueryString(options)
373
- const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, {
374
- method: 'POST',
375
- body: JSON.stringify(payload),
376
- ...options,
377
- cacheTtl: 0, // writes never use cache
378
- })
379
- this.cache.invalidateTag(collection) // optimistic invalidation
380
- return data.data || data
381
- }
382
-
383
- /**
384
- * Update an existing document.
385
- * Invalidates cache for the target collection.
386
- */
387
- async update<C extends string>(
388
- collection: C,
389
- id: string,
390
- payload: Partial<DocType<C>>,
391
- options: FetchOptions = {}
392
- ): Promise<DocType<C>> {
393
- const qs = this.buildQueryString(options)
394
- const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
395
- method: 'PATCH',
396
- body: JSON.stringify(payload),
397
- ...options,
398
- cacheTtl: 0,
399
- })
400
- this.cache.invalidateTag(collection)
401
- return data.data?.document || data.data || data
402
- }
403
-
404
- /**
405
- * Delete a document.
406
- * Invalidates cache for the target collection.
407
- */
408
- async delete<C extends string>(collection: C, id: string, options: FetchOptions = {}): Promise<DocType<C>> {
409
- const qs = this.buildQueryString(options)
410
- const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
411
- method: 'DELETE',
412
- ...options,
413
- cacheTtl: 0,
414
- })
415
- this.cache.invalidateTag(collection)
416
- return data.data?.document || data.data || data
417
- }
418
-
419
- // ── Aggregation & Counts ────────────────────────────────────────────────────
420
-
421
- /**
422
- * Count documents matching a filter.
423
- * Uses SWR cache.
424
- */
425
- async count<C extends string>(collection: C, filter?: Record<string, any>): Promise<number> {
426
- const params = new URLSearchParams()
427
- if (filter) {
428
- Object.entries(filter).forEach(([k, v]) => {
429
- if (v !== undefined && v !== null) params.append(`where[${k}]`, String(v))
430
- })
431
- }
432
- const paramStr = params.toString()
433
- const qs = paramStr ? `?${paramStr}` : ''
434
- const data = await this.fetchAPI(
435
- `/api/v1/${collection}/count${qs}`,
436
- { method: 'GET', cacheTtl: 0 }
437
- )
438
- return typeof data.data?.count === 'number' ? data.data.count : (data.count ?? 0)
439
- }
440
-
441
- /**
442
- * Run an aggregation pipeline on a collection.
443
- * Sends the pipeline to a dedicated endpoint.
444
- */
445
- async aggregate<C extends string>(
446
- collection: C,
447
- pipeline: Record<string, unknown>[]
448
- ): Promise<unknown[]> {
449
- const data = await this.fetchAPI(`/api/v1/${collection}/aggregate`, {
450
- method: 'POST',
451
- body: JSON.stringify({ pipeline }),
452
- cacheTtl: 0,
453
- })
454
- return data.data?.results ?? data.results ?? data
455
- }
456
-
457
- // ── Batch Operations ────────────────────────────────────────────────────────
458
-
459
- /**
460
- * Execute multiple API calls in a single round-trip (parallel).
461
- * All requests fire concurrently; waits for all to settle.
462
- * Returns an array of results in the same order as the input requests.
463
- *
464
- * @example
465
- * const [posts, authors] = await client.batch([
466
- * { method: 'GET', path: '/api/v1/posts?limit=10' },
467
- * { method: 'GET', path: '/api/v1/authors?limit=5' },
468
- * ])
469
- */
470
- async batch(requests: BatchRequest[]): Promise<any[]> {
471
- const results = await Promise.all(
472
- requests.map(async (req) => {
473
- const headers = this.buildHeaders(req.headers)
474
- try {
475
- const response = await fetch(`${this.url}${req.path}`, {
476
- method: req.method || 'GET',
477
- headers,
478
- body: req.body ? JSON.stringify(req.body) : undefined,
479
- })
480
- const data = await response.json().catch(() => null)
481
- if (!response.ok) {
482
- throw new Error(data?.message || `Batch item failed: ${response.status}`)
483
- }
484
- return data.data ?? data
485
- } catch (err) {
486
- // Propagate errors so Promise.allSettled-like behavior is available via .catch
487
- throw err
488
- }
489
- })
490
- )
491
- return results
492
- }
493
-
494
- // ── File Upload ─────────────────────────────────────────────────────────────
495
-
496
- /**
497
- * Upload a file (image, video, PDF, etc.) to Zenith CMS media store.
498
- *
499
- * @param file - File or Blob from <input type="file"> or FileReader
500
- * @param metadata - Optional alt text, focal point, folder
501
- */
502
- async upload(
503
- file: File | Blob,
504
- metadata?: {
505
- alt?: string
506
- focalPoint?: { x: number; y: number }
507
- folder?: string
508
- }
509
- ): Promise<any> {
510
- const formData = new FormData()
511
- const fileName = 'name' in file ? (file as any).name : 'file'
512
- formData.append('file', file, fileName)
513
-
514
- // Attach focal point as JSON string (multer parses it server-side)
515
- if (metadata?.focalPoint) {
516
- formData.append('focalPoint', JSON.stringify(metadata.focalPoint))
517
- }
518
- if (metadata?.alt) {
519
- formData.append('alt', metadata.alt)
520
- }
521
- if (metadata?.folder) {
522
- formData.append('folder', metadata.folder)
523
- }
524
-
525
- const headers = this.buildHeaders()
526
- // Remove Content-Type so fetch sets the correct multipart boundary
527
- headers.delete('Content-Type')
528
-
529
- const response = await fetch(`${this.url}/api/v1/upload`, {
530
- method: 'POST',
531
- headers,
532
- body: formData,
533
- })
534
-
535
- const data = await response.json().catch(() => null)
536
- if (!response.ok) {
537
- throw new Error(data?.message || `Upload failed: ${response.status}`)
538
- }
539
- return data.data ?? data
540
- }
541
-
542
- /**
543
- * Upload multiple files in parallel.
544
- * Uses Promise.all for concurrent uploads.
545
- */
546
- async uploadMany(
547
- files: (File | Blob)[],
548
- metadata?: Parameters<typeof this.upload>[1]
549
- ): Promise<any[]> {
550
- return Promise.all(files.map((file) => this.upload(file, metadata)))
551
- }
552
- }
553
-
554
- export function createClient(options: ZenithClientOptions): ZenithClient {
555
- return new ZenithClient(options)
556
- }
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "declaration": true,
7
- "outDir": "./dist",
8
- "rootDir": "./src",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "types": []
14
- },
15
- "include": ["src"],
16
- "exclude": ["**/*.test.ts"]
17
- }