@zenith-open/zenithcms-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,29 @@
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
+ }
@@ -0,0 +1,117 @@
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 ADDED
@@ -0,0 +1,556 @@
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 ADDED
@@ -0,0 +1,17 @@
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
+ }