@zenith-open/zenithcms-sdk 1.0.0-beta.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 +21 -0
- package/dist/cache.d.ts +21 -0
- package/dist/cache.js +55 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.js +129 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +413 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +95 -0
- package/dist/src/index.d.ts +43 -0
- package/dist/src/index.js +86 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +16 -0
- package/package.json +33 -0
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.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zenith Advanced Cache Service
|
|
3
|
+
* ─────────────────────────────
|
|
4
|
+
* Features:
|
|
5
|
+
* 1. Tag-based invalidation (e.g., invalidate all 'posts')
|
|
6
|
+
* 2. TTL support
|
|
7
|
+
* 3. Atomic clears
|
|
8
|
+
*/
|
|
9
|
+
export declare class CacheService {
|
|
10
|
+
private static cache;
|
|
11
|
+
private static tags;
|
|
12
|
+
static initialize(): void;
|
|
13
|
+
static get<T>(key: string): T | undefined;
|
|
14
|
+
static set(key: string, value: unknown, ttl?: number, tags?: string[]): void;
|
|
15
|
+
static del(keys: string | string[]): void;
|
|
16
|
+
/**
|
|
17
|
+
* Invalidate all keys associated with a tag
|
|
18
|
+
*/
|
|
19
|
+
static invalidateTag(tag: string): void;
|
|
20
|
+
static flush(): void;
|
|
21
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import NodeCache from 'node-cache';
|
|
2
|
+
import { logger } from './logger';
|
|
3
|
+
/**
|
|
4
|
+
* Zenith Advanced Cache Service
|
|
5
|
+
* ─────────────────────────────
|
|
6
|
+
* Features:
|
|
7
|
+
* 1. Tag-based invalidation (e.g., invalidate all 'posts')
|
|
8
|
+
* 2. TTL support
|
|
9
|
+
* 3. Atomic clears
|
|
10
|
+
*/
|
|
11
|
+
export class CacheService {
|
|
12
|
+
static cache = new NodeCache({ stdTTL: 600 }); // 10 min default
|
|
13
|
+
static tags = {};
|
|
14
|
+
// Handle key expiration to prevent memory leaks in the tags array
|
|
15
|
+
static initialize() {
|
|
16
|
+
this.cache.on('expired', (key) => {
|
|
17
|
+
Object.keys(this.tags).forEach((tag) => {
|
|
18
|
+
this.tags[tag] = this.tags[tag].filter((k) => k !== key);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
static get(key) {
|
|
23
|
+
return this.cache.get(key);
|
|
24
|
+
}
|
|
25
|
+
static set(key, value, ttl, tags = []) {
|
|
26
|
+
this.cache.set(key, value, ttl || 600);
|
|
27
|
+
// Track tags
|
|
28
|
+
tags.forEach((tag) => {
|
|
29
|
+
if (!this.tags[tag])
|
|
30
|
+
this.tags[tag] = [];
|
|
31
|
+
if (!this.tags[tag].includes(key)) {
|
|
32
|
+
this.tags[tag].push(key);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
static del(keys) {
|
|
37
|
+
this.cache.del(keys);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Invalidate all keys associated with a tag
|
|
41
|
+
*/
|
|
42
|
+
static invalidateTag(tag) {
|
|
43
|
+
const keys = this.tags[tag];
|
|
44
|
+
if (keys && keys.length > 0) {
|
|
45
|
+
logger.info({ tag, count: keys.length }, 'Invalidating cache tag');
|
|
46
|
+
this.cache.del(keys);
|
|
47
|
+
this.tags[tag] = [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
static flush() {
|
|
51
|
+
this.cache.flushAll();
|
|
52
|
+
this.tags = {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
CacheService.initialize();
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ZenithClientOptions {
|
|
2
|
+
url: string;
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
siteId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FetchOptions extends RequestInit {
|
|
7
|
+
locale?: string;
|
|
8
|
+
depth?: number;
|
|
9
|
+
drafts?: boolean;
|
|
10
|
+
}
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
+
export interface FindOptions extends FetchOptions {
|
|
13
|
+
where?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
14
|
+
sort?: string;
|
|
15
|
+
limit?: number;
|
|
16
|
+
page?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ZenithClient {
|
|
22
|
+
private url;
|
|
23
|
+
private apiKey?;
|
|
24
|
+
private siteId?;
|
|
25
|
+
constructor(options: ZenithClientOptions);
|
|
26
|
+
private buildQueryString;
|
|
27
|
+
private flattenWhereParams;
|
|
28
|
+
private fetchAPI;
|
|
29
|
+
/**
|
|
30
|
+
* Find multiple documents in a collection.
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
find<T = any>(collection: string, options?: FindOptions): Promise<{ // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
34
|
+
docs: T[];
|
|
35
|
+
totalDocs: number;
|
|
36
|
+
totalPages: number;
|
|
37
|
+
page: number;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Find a single document by its ID.
|
|
41
|
+
*/
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
findById<T = any>(collection: string, id: string, options?: FetchOptions): Promise<T>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
44
|
+
/**
|
|
45
|
+
* Fetch a singleton configuration.
|
|
46
|
+
*/
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
findGlobal<T = any>(slug: string, options?: FetchOptions): Promise<T>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
49
|
+
/**
|
|
50
|
+
* Create a new document in a collection.
|
|
51
|
+
*/
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
create<T = any>(collection: string, payload: any, options?: FetchOptions): Promise<T>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
54
|
+
/**
|
|
55
|
+
* Update an existing document.
|
|
56
|
+
*/
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
update<T = any>(collection: string, id: string, payload: any, options?: FetchOptions): Promise<T>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
59
|
+
/**
|
|
60
|
+
* Delete a document.
|
|
61
|
+
*/
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
delete<T = any>(collection: string, id: string, options?: FetchOptions): Promise<T>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
64
|
+
}
|
|
65
|
+
export declare function createClient(options: ZenithClientOptions): ZenithClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight JavaScript client for Zenith CMS, optimized for Edge environments.
|
|
3
|
+
*/
|
|
4
|
+
export class ZenithClient {
|
|
5
|
+
url;
|
|
6
|
+
apiKey;
|
|
7
|
+
siteId;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.url = options.url.replace(/\/$/, '');
|
|
10
|
+
this.apiKey = options.apiKey;
|
|
11
|
+
this.siteId = options.siteId;
|
|
12
|
+
}
|
|
13
|
+
buildQueryString(options) {
|
|
14
|
+
const params = new URLSearchParams();
|
|
15
|
+
if (options.locale)
|
|
16
|
+
params.append('locale', options.locale);
|
|
17
|
+
if (options.depth !== undefined)
|
|
18
|
+
params.append('depth', String(options.depth));
|
|
19
|
+
if (options.drafts)
|
|
20
|
+
params.append('drafts', 'true');
|
|
21
|
+
if (options.sort)
|
|
22
|
+
params.append('sort', options.sort);
|
|
23
|
+
if (options.limit !== undefined)
|
|
24
|
+
params.append('limit', String(options.limit));
|
|
25
|
+
if (options.page !== undefined)
|
|
26
|
+
params.append('page', String(options.page));
|
|
27
|
+
if (options.where) {
|
|
28
|
+
this.flattenWhereParams(options.where, 'where').forEach((value, key) => {
|
|
29
|
+
params.append(key, value);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const str = params.toString();
|
|
33
|
+
return str ? `?${str}` : '';
|
|
34
|
+
}
|
|
35
|
+
flattenWhereParams(obj, prefix) {
|
|
36
|
+
const map = new Map();
|
|
37
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
38
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
39
|
+
const nested = this.flattenWhereParams(value, `${prefix}[${key}]`);
|
|
40
|
+
nested.forEach((v, k) => map.set(k, v));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
map.set(`${prefix}[${key}]`, String(value));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
async fetchAPI(path, options = {}) {
|
|
49
|
+
const headers = new Headers(options.headers);
|
|
50
|
+
headers.set('Content-Type', 'application/json');
|
|
51
|
+
if (this.apiKey) {
|
|
52
|
+
headers.set('Authorization', `Bearer ${this.apiKey}`);
|
|
53
|
+
}
|
|
54
|
+
if (this.siteId) {
|
|
55
|
+
headers.set('X-Zenith-Site-Id', this.siteId);
|
|
56
|
+
}
|
|
57
|
+
const response = await fetch(`${this.url}${path}`, {
|
|
58
|
+
...options,
|
|
59
|
+
headers,
|
|
60
|
+
});
|
|
61
|
+
const data = await response.json().catch(() => null);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(data?.message || `Zenith API error: ${response.status} ${response.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Find multiple documents in a collection.
|
|
69
|
+
*/
|
|
70
|
+
async find(collection, options = {}) {
|
|
71
|
+
const qs = this.buildQueryString(options);
|
|
72
|
+
const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, { method: 'GET', ...options });
|
|
73
|
+
return data.data || data; // Handles different API response envelopes
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Find a single document by its ID.
|
|
77
|
+
*/
|
|
78
|
+
async findById(collection, id, options = {}) {
|
|
79
|
+
const qs = this.buildQueryString(options);
|
|
80
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, { method: 'GET', ...options });
|
|
81
|
+
return data.data?.document || data.data || data;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fetch a singleton configuration.
|
|
85
|
+
*/
|
|
86
|
+
async findGlobal(slug, options = {}) {
|
|
87
|
+
const qs = this.buildQueryString(options);
|
|
88
|
+
const data = await this.fetchAPI(`/api/v1/globals/${slug}${qs}`, { method: 'GET', ...options });
|
|
89
|
+
return data.data?.document || data.data || data;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a new document in a collection.
|
|
93
|
+
*/
|
|
94
|
+
async create(collection, payload, options = {}) {
|
|
95
|
+
const qs = this.buildQueryString(options);
|
|
96
|
+
const data = await this.fetchAPI(`/api/v1/${collection}${qs}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: JSON.stringify(payload),
|
|
99
|
+
...options,
|
|
100
|
+
});
|
|
101
|
+
return data.data || data;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Update an existing document.
|
|
105
|
+
*/
|
|
106
|
+
async update(collection, id, payload, options = {}) {
|
|
107
|
+
const qs = this.buildQueryString(options);
|
|
108
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
109
|
+
method: 'PATCH',
|
|
110
|
+
body: JSON.stringify(payload),
|
|
111
|
+
...options,
|
|
112
|
+
});
|
|
113
|
+
return data.data?.document || data.data || data;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Delete a document.
|
|
117
|
+
*/
|
|
118
|
+
async delete(collection, id, options = {}) {
|
|
119
|
+
const qs = this.buildQueryString(options);
|
|
120
|
+
const data = await this.fetchAPI(`/api/v1/${collection}/${id}${qs}`, {
|
|
121
|
+
method: 'DELETE',
|
|
122
|
+
...options,
|
|
123
|
+
});
|
|
124
|
+
return data.data?.document || data.data || data;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function createClient(options) {
|
|
128
|
+
return new ZenithClient(options);
|
|
129
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ZenithClient } from './index';
|
|
3
|
+
// Minimal fetch mock — intercepts calls and returns JSON
|
|
4
|
+
function makeClient() {
|
|
5
|
+
return new ZenithClient({ url: 'http://localhost:3000' });
|
|
6
|
+
}
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
global.fetch = vi.fn();
|
|
9
|
+
});
|
|
10
|
+
describe('ZenithClient — SWR cache', () => {
|
|
11
|
+
it('serves stale data immediately and revalidates in background', async () => {
|
|
12
|
+
const client = makeClient();
|
|
13
|
+
const mockData = { data: { docs: [{ _id: '1', title: 'Cached Post' }] } };
|
|
14
|
+
fetch.mockResolvedValueOnce({
|
|
15
|
+
ok: true,
|
|
16
|
+
json: () => Promise.resolve(mockData),
|
|
17
|
+
});
|
|
18
|
+
const result1 = await client.find('posts', { limit: 5, cacheTtl: 30_000 });
|
|
19
|
+
expect(result1.docs[0].title).toBe('Cached Post');
|
|
20
|
+
// Second request should return cached data immediately without waiting
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
const result2 = await client.find('posts', { limit: 5 });
|
|
23
|
+
const elapsed = Date.now() - start;
|
|
24
|
+
// With cache hit and SWR revalidation, this should be near-instant
|
|
25
|
+
expect(elapsed).toBeLessThan(50);
|
|
26
|
+
expect(result2.docs[0].title).toBe('Cached Post');
|
|
27
|
+
});
|
|
28
|
+
it('bypasses cache when cacheTtl is 0', async () => {
|
|
29
|
+
const client = makeClient();
|
|
30
|
+
fetch.mockResolvedValue({
|
|
31
|
+
ok: true,
|
|
32
|
+
json: () => Promise.resolve({ data: { docs: [] } }),
|
|
33
|
+
});
|
|
34
|
+
// Should call fetch both times
|
|
35
|
+
await client.find('posts', { cacheTtl: 0 });
|
|
36
|
+
await client.find('posts', { cacheTtl: 0 });
|
|
37
|
+
expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('ZenithClient — batch', () => {
|
|
41
|
+
it('executes multiple requests in parallel', async () => {
|
|
42
|
+
const client = makeClient();
|
|
43
|
+
fetch.mockResolvedValue({
|
|
44
|
+
ok: true,
|
|
45
|
+
json: () => Promise.resolve({ data: { posts: [] } }),
|
|
46
|
+
});
|
|
47
|
+
await client.batch([
|
|
48
|
+
{ method: 'GET', path: '/api/beta/posts' },
|
|
49
|
+
{ method: 'GET', path: '/api/beta/authors' },
|
|
50
|
+
]);
|
|
51
|
+
expect(fetch.mock.calls.length).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('ZenithClient — upload', () => {
|
|
55
|
+
it('sends FormData for file uploads and omits Content-Type header', async () => {
|
|
56
|
+
const client = makeClient();
|
|
57
|
+
fetch.mockResolvedValue({
|
|
58
|
+
ok: true,
|
|
59
|
+
json: () => Promise.resolve({ data: { _id: '123', url: 'http://cdn/img.jpg' } }),
|
|
60
|
+
});
|
|
61
|
+
const file = new File(['hello'], 'test.jpg', { type: 'image/jpeg' });
|
|
62
|
+
await client.upload(file, { alt: 'Test alt', focalPoint: { x: 50, y: 50 } });
|
|
63
|
+
const [url, options] = fetch.mock.calls[0];
|
|
64
|
+
expect(url).toContain('/api/beta/upload');
|
|
65
|
+
expect(options.headers.get('Content-Type')).toBeNull(); // fetch auto-sets multipart
|
|
66
|
+
expect(options.method).toBe('POST');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('ZenithClient — site switching', () => {
|
|
70
|
+
it('updates siteId and flushes cache when setSiteId is called', async () => {
|
|
71
|
+
const client = makeClient();
|
|
72
|
+
const mockData1 = { data: { docs: [{ _id: '1', title: 'Post Site A' }] } };
|
|
73
|
+
const mockData2 = { data: { docs: [{ _id: '2', title: 'Post Site B' }] } };
|
|
74
|
+
fetch
|
|
75
|
+
.mockResolvedValueOnce({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () => Promise.resolve(mockData1),
|
|
78
|
+
})
|
|
79
|
+
.mockResolvedValueOnce({
|
|
80
|
+
ok: true,
|
|
81
|
+
json: () => Promise.resolve(mockData2),
|
|
82
|
+
});
|
|
83
|
+
// Fetch on default site ID (empty)
|
|
84
|
+
const res1 = await client.find('posts', { limit: 5 });
|
|
85
|
+
expect(res1.docs[0].title).toBe('Post Site A');
|
|
86
|
+
// Change site ID using setSiteId
|
|
87
|
+
client.setSiteId('site-b');
|
|
88
|
+
// Fetch again — cache should be flushed, performing a new fetch with headers
|
|
89
|
+
const res2 = await client.find('posts', { limit: 5 });
|
|
90
|
+
expect(res2.docs[0].title).toBe('Post Site B');
|
|
91
|
+
// Verify correct header was sent in the second request
|
|
92
|
+
const lastCall = fetch.mock.calls[1];
|
|
93
|
+
expect(lastCall[1].headers.get('X-Zenith-Site-Id')).toBe('site-b');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
interface ZenithConfig {
|
|
2
|
+
baseURL: string;
|
|
3
|
+
token?: string;
|
|
4
|
+
cacheTTL?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Zenith SDK — Universal Client
|
|
8
|
+
* ─────────────────────────────
|
|
9
|
+
* A high-performance, type-safe client for consuming Zenith CMS content.
|
|
10
|
+
* Features built-in caching and automated error handling.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ZenithClient {
|
|
13
|
+
private api;
|
|
14
|
+
private cache;
|
|
15
|
+
private ttl;
|
|
16
|
+
constructor(config: ZenithConfig);
|
|
17
|
+
/**
|
|
18
|
+
* Fetch a list of entries from a collection
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
find<T = any>(slug: string, params?: Record<string, any>): Promise<{
|
|
22
|
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
23
|
+
data: T[];
|
|
24
|
+
meta: any;
|
|
1
25
|
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a single entry by ID
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
findOne<T = any>(slug: string, id: string): Promise<{
|
|
2
32
|
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
33
|
+
data: T;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Fetch a singleton collection
|
|
37
|
+
*/
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
getSingleton<T = any>(slug: string): Promise<{
|
|
3
40
|
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
41
|
+
data: T;
|
|
42
|
+
}>;
|
|
43
|
+
private getCache;
|
|
44
|
+
private setCache;
|
|
45
|
+
private handleError;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
/**
|
|
3
|
+
* Zenith SDK — Universal Client
|
|
4
|
+
* ─────────────────────────────
|
|
5
|
+
* A high-performance, type-safe client for consuming Zenith CMS content.
|
|
6
|
+
* Features built-in caching and automated error handling.
|
|
7
|
+
*/
|
|
8
|
+
export class ZenithClient {
|
|
9
|
+
api;
|
|
10
|
+
cache = new Map();
|
|
11
|
+
ttl;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.ttl = config.cacheTTL || 60000; // Default 1 min
|
|
14
|
+
this.api = axios.create({
|
|
15
|
+
baseURL: config.baseURL.endsWith('/api/beta') ? config.baseURL : `${config.baseURL}/api/beta`,
|
|
16
|
+
headers: config.token ? { Authorization: `Bearer ${config.token}` } : {},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Fetch a list of entries from a collection
|
|
21
|
+
*/
|
|
22
|
+
async find(slug, params = {}) {
|
|
23
|
+
const cacheKey = `${slug}:list:${JSON.stringify(params)}`;
|
|
24
|
+
const cached = this.getCache(cacheKey);
|
|
25
|
+
if (cached)
|
|
26
|
+
return cached;
|
|
27
|
+
try {
|
|
28
|
+
const res = await this.api.get(`/${slug}`, { params });
|
|
29
|
+
this.setCache(cacheKey, res.data);
|
|
30
|
+
return res.data;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
this.handleError(err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Fetch a single entry by ID
|
|
38
|
+
*/
|
|
39
|
+
async findOne(slug, id) {
|
|
40
|
+
const cacheKey = `${slug}:${id}`;
|
|
41
|
+
const cached = this.getCache(cacheKey);
|
|
42
|
+
if (cached)
|
|
43
|
+
return cached;
|
|
44
|
+
try {
|
|
45
|
+
const res = await this.api.get(`/${slug}/${id}`);
|
|
46
|
+
this.setCache(cacheKey, res.data);
|
|
47
|
+
return res.data;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
this.handleError(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fetch a singleton collection
|
|
55
|
+
*/
|
|
56
|
+
async getSingleton(slug) {
|
|
57
|
+
const cacheKey = `singleton:${slug}`;
|
|
58
|
+
const cached = this.getCache(cacheKey);
|
|
59
|
+
if (cached)
|
|
60
|
+
return cached;
|
|
61
|
+
try {
|
|
62
|
+
const res = await this.api.get(`/${slug}`);
|
|
63
|
+
this.setCache(cacheKey, res.data);
|
|
64
|
+
return res.data;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
this.handleError(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// --- Cache Helpers ---
|
|
71
|
+
getCache(key) {
|
|
72
|
+
const cached = this.cache.get(key);
|
|
73
|
+
if (cached && cached.expiry > Date.now())
|
|
74
|
+
return cached.data;
|
|
75
|
+
if (cached)
|
|
76
|
+
this.cache.delete(key);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
setCache(key, data) {
|
|
80
|
+
this.cache.set(key, { data, expiry: Date.now() + this.ttl });
|
|
81
|
+
}
|
|
82
|
+
handleError(err) {
|
|
83
|
+
const message = err.response?.data?.error?.message || err.message || 'Unknown Zenith SDK Error';
|
|
84
|
+
throw new Error(`[Zenith SDK] ${message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { paths } from './schema';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a collection name string to its document type via OpenAPI schema paths.
|
|
4
|
+
* Falls back to unknown for unknown collections.
|
|
5
|
+
*/
|
|
6
|
+
type GetPath<C extends string> = `/${C}`;
|
|
7
|
+
type ExtractArrayItem<T> = T extends (infer U)[] ? U : unknown;
|
|
8
|
+
type OpenAPIResponse<P extends string> = P extends keyof paths ? 'get' extends keyof paths[P] ? 'responses' extends keyof paths[P]['get'] ? 200 extends keyof paths[P]['get']['responses'] ? 'content' extends keyof paths[P]['get']['responses'][200] ? 'application/json' extends keyof paths[P]['get']['responses'][200]['content'] ? 'schema' extends keyof paths[P]['get']['responses'][200]['content']['application/json'] ? paths[P]['get']['responses'][200]['content']['application/json']['schema'] : unknown : unknown : unknown : unknown : unknown : unknown : unknown;
|
|
9
|
+
export type DocType<C extends string> = OpenAPIResponse<GetPath<C>> extends never ? unknown : ExtractArrayItem<OpenAPIResponse<GetPath<C>>>;
|
|
10
|
+
export interface ZenithClientOptions {
|
|
11
|
+
url: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
siteId?: string;
|
|
14
|
+
/** SWR cache TTL in ms. default 30_000 (30s). set 0 to disable. */
|
|
15
|
+
cacheTtl?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface FetchOptions extends RequestInit {
|
|
18
|
+
locale?: string;
|
|
19
|
+
depth?: number;
|
|
20
|
+
drafts?: boolean;
|
|
21
|
+
populate?: string[] | string;
|
|
22
|
+
select?: string[] | string;
|
|
23
|
+
/** Override the global cache TTL for this request. 0 = bypass cache. */
|
|
24
|
+
cacheTtl?: number;
|
|
25
|
+
/** Tag-based cache invalidation key for this request */
|
|
26
|
+
cacheTag?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FindOptions extends FetchOptions {
|
|
29
|
+
where?: Record<string, unknown>;
|
|
30
|
+
sort?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
page?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface BatchRequest {
|
|
35
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
36
|
+
path: string;
|
|
37
|
+
body?: unknown;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
/** Structured error thrown by all ZenithClient methods. */
|
|
41
|
+
export declare class ZenithAPIError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
readonly code?: string;
|
|
44
|
+
readonly isNetworkError: boolean;
|
|
45
|
+
readonly isParseError: boolean;
|
|
46
|
+
constructor(opts: {
|
|
47
|
+
message: string;
|
|
48
|
+
status: number;
|
|
49
|
+
code?: string;
|
|
50
|
+
isNetworkError?: boolean;
|
|
51
|
+
isParseError?: boolean;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ── Error Handling ────────────────────────────────────────────────────────────
|
|
2
|
+
/** Structured error thrown by all ZenithClient methods. */
|
|
3
|
+
export class ZenithAPIError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
isNetworkError;
|
|
7
|
+
isParseError;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
super(opts.message);
|
|
10
|
+
this.name = 'ZenithAPIError';
|
|
11
|
+
this.status = opts.status;
|
|
12
|
+
this.code = opts.code;
|
|
13
|
+
this.isNetworkError = opts.isNetworkError ?? false;
|
|
14
|
+
this.isParseError = opts.isParseError ?? false;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zenith-open/zenithcms-sdk",
|
|
3
|
+
"version": "1.0.0-beta.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
|
+
"vitest": "^1.6.0",
|
|
22
|
+
"@zenith-open/zenithcms-types": "1.0.0-beta.1"
|
|
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
|
+
}
|