nodestack-client 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/dist/index.mjs ADDED
@@ -0,0 +1,948 @@
1
+ // src/auth/BaseAuthStore.ts
2
+ function decodeJwtPayload(token) {
3
+ if (!token || typeof token !== "string") return null;
4
+ const parts = token.split(".");
5
+ if (parts.length !== 3) return null;
6
+ try {
7
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
8
+ while (b64.length % 4) {
9
+ b64 += "=";
10
+ }
11
+ let jsonStr;
12
+ const globalObj = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof self !== "undefined" ? self : {};
13
+ if (typeof globalObj.atob === "function") {
14
+ jsonStr = decodeURIComponent(
15
+ Array.prototype.map.call(globalObj.atob(b64), (c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)).join("")
16
+ );
17
+ } else if (typeof globalObj.Buffer !== "undefined") {
18
+ jsonStr = globalObj.Buffer.from(b64, "base64").toString("utf8");
19
+ } else {
20
+ return null;
21
+ }
22
+ return JSON.parse(jsonStr);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ var BaseAuthStore = class {
28
+ _token = "";
29
+ _model = null;
30
+ _listeners = /* @__PURE__ */ new Set();
31
+ get token() {
32
+ return this._token;
33
+ }
34
+ get model() {
35
+ return this._model;
36
+ }
37
+ /**
38
+ * Returns true if a token exists and has not yet expired.
39
+ */
40
+ get isValid() {
41
+ if (!this._token) {
42
+ return false;
43
+ }
44
+ const payload = decodeJwtPayload(this._token);
45
+ if (!payload) {
46
+ return false;
47
+ }
48
+ if (payload.exp && typeof payload.exp === "number") {
49
+ const now = Math.floor(Date.now() / 1e3);
50
+ return payload.exp > now;
51
+ }
52
+ return true;
53
+ }
54
+ /**
55
+ * Saves the token and associated user or admin model.
56
+ */
57
+ save(token, model) {
58
+ this._token = token || "";
59
+ this._model = model || null;
60
+ this.notify();
61
+ }
62
+ /**
63
+ * Clears the current authentication state.
64
+ */
65
+ clear() {
66
+ this._token = "";
67
+ this._model = null;
68
+ this.notify();
69
+ }
70
+ /**
71
+ * Subscribes to changes in authentication state (token or model).
72
+ * Returns an unbind function.
73
+ */
74
+ onChange(callback, fireImmediately = false) {
75
+ this._listeners.add(callback);
76
+ if (fireImmediately) {
77
+ try {
78
+ callback(this._token, this._model);
79
+ } catch (err) {
80
+ console.error("[AuthStore] Error in immediate onChange listener:", err);
81
+ }
82
+ }
83
+ return () => {
84
+ this._listeners.delete(callback);
85
+ };
86
+ }
87
+ /**
88
+ * Triggers all subscribed change listeners.
89
+ */
90
+ notify() {
91
+ for (const listener of this._listeners) {
92
+ try {
93
+ listener(this._token, this._model);
94
+ } catch (err) {
95
+ console.error("[AuthStore] Error in onChange listener:", err);
96
+ }
97
+ }
98
+ }
99
+ };
100
+
101
+ // src/auth/LocalAuthStore.ts
102
+ var DEFAULT_STORAGE_KEY = "nodestack_auth";
103
+ var LocalAuthStore = class extends BaseAuthStore {
104
+ storageKey;
105
+ storage;
106
+ constructor(storageKey = DEFAULT_STORAGE_KEY, customStorage) {
107
+ super();
108
+ this.storageKey = storageKey;
109
+ if (customStorage) {
110
+ this.storage = customStorage;
111
+ } else if (typeof window !== "undefined" && window.localStorage) {
112
+ this.storage = window.localStorage;
113
+ }
114
+ this.loadInitial();
115
+ }
116
+ loadInitial() {
117
+ if (!this.storage) return;
118
+ try {
119
+ const raw = this.storage.getItem(this.storageKey);
120
+ if (raw) {
121
+ const parsed = JSON.parse(raw);
122
+ if (parsed && typeof parsed.token === "string") {
123
+ this._token = parsed.token;
124
+ this._model = parsed.model || null;
125
+ }
126
+ }
127
+ } catch {
128
+ }
129
+ }
130
+ save(token, model) {
131
+ super.save(token, model);
132
+ if (this.storage) {
133
+ try {
134
+ this.storage.setItem(
135
+ this.storageKey,
136
+ JSON.stringify({
137
+ token: this._token,
138
+ model: this._model
139
+ })
140
+ );
141
+ } catch {
142
+ }
143
+ }
144
+ }
145
+ clear() {
146
+ super.clear();
147
+ if (this.storage) {
148
+ try {
149
+ this.storage.removeItem(this.storageKey);
150
+ } catch {
151
+ }
152
+ }
153
+ }
154
+ };
155
+
156
+ // src/errors/ClientResponseError.ts
157
+ var ClientResponseError = class _ClientResponseError extends Error {
158
+ url;
159
+ status;
160
+ data;
161
+ isAbort;
162
+ originalError;
163
+ constructor(options = {}) {
164
+ const status = options.status || 0;
165
+ const message = options.message || options.data?.message || (status > 0 ? `NodeStack request failed with status ${status}` : "NodeStack network request failed");
166
+ super(message);
167
+ this.name = "ClientResponseError";
168
+ this.url = options.url || "";
169
+ this.status = status;
170
+ this.data = options.data || {};
171
+ this.isAbort = Boolean(options.isAbort);
172
+ this.originalError = options.originalError;
173
+ Object.setPrototypeOf(this, _ClientResponseError.prototype);
174
+ }
175
+ toJSON() {
176
+ return {
177
+ name: this.name,
178
+ message: this.message,
179
+ url: this.url,
180
+ status: this.status,
181
+ data: this.data,
182
+ isAbort: this.isAbort
183
+ };
184
+ }
185
+ };
186
+
187
+ // src/services/BaseService.ts
188
+ var BaseService = class {
189
+ constructor(client) {
190
+ this.client = client;
191
+ }
192
+ client;
193
+ send(path, options = {}) {
194
+ return this.client.send(path, options);
195
+ }
196
+ };
197
+
198
+ // src/services/RecordService.ts
199
+ var RecordService = class extends BaseService {
200
+ constructor(client, collectionName) {
201
+ super(client);
202
+ this.collectionName = collectionName;
203
+ }
204
+ collectionName;
205
+ get clientWithRealtime() {
206
+ return this.client;
207
+ }
208
+ /**
209
+ * Returns a paginated list of records matching the query options.
210
+ */
211
+ async getList(page = 1, perPage = 30, options = {}) {
212
+ return this.send(
213
+ `/api/collections/${encodeURIComponent(this.collectionName)}/records`,
214
+ {
215
+ method: "GET",
216
+ query: {
217
+ page,
218
+ perPage,
219
+ ...options
220
+ }
221
+ }
222
+ );
223
+ }
224
+ /**
225
+ * Fetches and aggregates all records in the collection across multiple pages.
226
+ */
227
+ async getFullList(options = {}) {
228
+ const batch = Math.max(1, Math.min(500, options.batch || 200));
229
+ const query = { ...options };
230
+ delete query.batch;
231
+ const firstPage = await this.getList(1, batch, query);
232
+ const items = [...firstPage.items];
233
+ if (firstPage.totalPages <= 1) {
234
+ return items;
235
+ }
236
+ for (let page = 2; page <= firstPage.totalPages; page++) {
237
+ const result = await this.getList(page, batch, query);
238
+ items.push(...result.items);
239
+ }
240
+ return items;
241
+ }
242
+ /**
243
+ * Returns the first record matching the specified filter expression.
244
+ * Throws ClientResponseError(404) if no record matches.
245
+ */
246
+ async getFirstListItem(filter, options = {}) {
247
+ const list = await this.getList(1, 1, {
248
+ ...options,
249
+ filter
250
+ });
251
+ if (!list.items || list.items.length === 0) {
252
+ throw new ClientResponseError({
253
+ status: 404,
254
+ url: `/api/collections/${this.collectionName}/records`,
255
+ message: `The requested record was not found in collection '${this.collectionName}'.`
256
+ });
257
+ }
258
+ return list.items[0];
259
+ }
260
+ /**
261
+ * Retrieves a single record by its ID.
262
+ */
263
+ async getOne(id, options = {}) {
264
+ return this.send(
265
+ `/api/collections/${encodeURIComponent(this.collectionName)}/records/${encodeURIComponent(id)}`,
266
+ {
267
+ method: "GET",
268
+ query: options
269
+ }
270
+ );
271
+ }
272
+ /**
273
+ * Creates a new record in the collection.
274
+ * Accepts a plain object or FormData (for file uploads).
275
+ */
276
+ async create(data, options = {}) {
277
+ return this.send(
278
+ `/api/collections/${encodeURIComponent(this.collectionName)}/records`,
279
+ {
280
+ method: "POST",
281
+ body: data,
282
+ query: options
283
+ }
284
+ );
285
+ }
286
+ /**
287
+ * Updates an existing record by its ID.
288
+ * Accepts a plain object or FormData (for file updates).
289
+ */
290
+ async update(id, data, options = {}) {
291
+ return this.send(
292
+ `/api/collections/${encodeURIComponent(this.collectionName)}/records/${encodeURIComponent(id)}`,
293
+ {
294
+ method: "PATCH",
295
+ body: data,
296
+ query: options
297
+ }
298
+ );
299
+ }
300
+ /**
301
+ * Deletes a record by its ID.
302
+ */
303
+ async delete(id) {
304
+ await this.send(
305
+ `/api/collections/${encodeURIComponent(this.collectionName)}/records/${encodeURIComponent(id)}`,
306
+ {
307
+ method: "DELETE"
308
+ }
309
+ );
310
+ return true;
311
+ }
312
+ /**
313
+ * Authenticates against an auth collection using identity/email and password.
314
+ * On success, automatically persists the token and record to authStore.
315
+ */
316
+ async authWithPassword(identity, password) {
317
+ const result = await this.send(
318
+ `/api/collections/${encodeURIComponent(this.collectionName)}/auth-with-password`,
319
+ {
320
+ method: "POST",
321
+ body: { identity, password }
322
+ }
323
+ );
324
+ if (result && result.token) {
325
+ this.client.authStore.save(result.token, result.record);
326
+ }
327
+ return result;
328
+ }
329
+ /**
330
+ * Exports records from the collection as CSV or JSON.
331
+ */
332
+ async export(format = "csv", options = {}) {
333
+ return this.send(
334
+ `/api/collections/${encodeURIComponent(this.collectionName)}/export`,
335
+ {
336
+ method: "GET",
337
+ query: {
338
+ ...options,
339
+ format
340
+ }
341
+ }
342
+ );
343
+ }
344
+ /**
345
+ * Imports records into the collection.
346
+ */
347
+ async import(data, options = {}) {
348
+ return this.send(
349
+ `/api/collections/${encodeURIComponent(this.collectionName)}/import`,
350
+ {
351
+ method: "POST",
352
+ body: data,
353
+ query: options
354
+ }
355
+ );
356
+ }
357
+ /**
358
+ * Subscribes to realtime events on this collection or a specific record ID.
359
+ *
360
+ * Example:
361
+ * // Listen to all records in collection:
362
+ * client.collection('posts').subscribe('*', (e) => console.log(e.action, e.record));
363
+ *
364
+ * // Listen to single record changes:
365
+ * client.collection('posts').subscribe('record_id_123', (e) => console.log(e.action, e.record));
366
+ */
367
+ async subscribe(topicOrCallback, callback) {
368
+ let topic = "*";
369
+ let cb;
370
+ if (typeof topicOrCallback === "function") {
371
+ topic = "*";
372
+ cb = topicOrCallback;
373
+ } else {
374
+ topic = topicOrCallback;
375
+ cb = callback;
376
+ }
377
+ const fullTopic = topic === "*" ? this.collectionName : `${this.collectionName}/${topic}`;
378
+ return this.clientWithRealtime.realtime.subscribe(fullTopic, cb);
379
+ }
380
+ /**
381
+ * Unsubscribes from realtime events for this collection.
382
+ */
383
+ async unsubscribe(topic) {
384
+ const fullTopic = !topic || topic === "*" ? this.collectionName : `${this.collectionName}/${topic}`;
385
+ return this.clientWithRealtime.realtime.unsubscribe(fullTopic);
386
+ }
387
+ };
388
+
389
+ // src/services/FileService.ts
390
+ var FileService = class extends BaseService {
391
+ /**
392
+ * Generates a fully qualified URL to an uploaded file.
393
+ *
394
+ * @param record - Record object or record ID string
395
+ * @param filename - Name of the file stored on the record
396
+ * @param options - Additional options (download flag, auth token, collection override)
397
+ */
398
+ getUrl(record, filename, options = {}) {
399
+ if (!filename) return "";
400
+ let recordId = "";
401
+ let collectionName = options.collection || "";
402
+ if (typeof record === "string") {
403
+ recordId = record;
404
+ } else if (record && typeof record === "object") {
405
+ recordId = record.id || "";
406
+ collectionName = collectionName || record.collection || record.collectionName || record["@collectionName"] || "";
407
+ }
408
+ if (!recordId) {
409
+ throw new Error("[FileService] A valid record or recordId is required to construct a file URL.");
410
+ }
411
+ if (!collectionName) {
412
+ throw new Error(
413
+ "[FileService] Collection name is required. Either provide a record with collection info or pass options.collection."
414
+ );
415
+ }
416
+ const cleanBase = (this.client.baseUrl || "").replace(/\/+$/, "");
417
+ const cleanCol = encodeURIComponent(collectionName);
418
+ const cleanId = encodeURIComponent(recordId);
419
+ const cleanFile = encodeURIComponent(filename);
420
+ let url = `${cleanBase}/api/files/${cleanCol}/${cleanId}/${cleanFile}`;
421
+ const queryParts = [];
422
+ if (options.download) {
423
+ queryParts.push("download=1");
424
+ }
425
+ if (typeof options.token === "string" && options.token) {
426
+ queryParts.push(`token=${encodeURIComponent(options.token)}`);
427
+ } else if (options.token === true && this.client.authStore.token) {
428
+ queryParts.push(`token=${encodeURIComponent(this.client.authStore.token)}`);
429
+ }
430
+ if (queryParts.length > 0) {
431
+ url += `?${queryParts.join("&")}`;
432
+ }
433
+ return url;
434
+ }
435
+ };
436
+
437
+ // src/services/RealtimeService.ts
438
+ var RealtimeService = class extends BaseService {
439
+ eventSource = null;
440
+ clientId = "";
441
+ isConnecting = false;
442
+ connectPromise = null;
443
+ reconnectTimeout = null;
444
+ reconnectAttempts = 0;
445
+ customEventSource;
446
+ // Map of topic -> Set of callbacks
447
+ subscriptions = /* @__PURE__ */ new Map();
448
+ // Set of registered event names on the active EventSource
449
+ registeredEventNames = /* @__PURE__ */ new Set();
450
+ constructor(client, customEventSource) {
451
+ super(client);
452
+ this.customEventSource = customEventSource;
453
+ }
454
+ /**
455
+ * Returns whether there is an active EventSource connection.
456
+ */
457
+ get isConnected() {
458
+ return Boolean(this.eventSource && this.clientId);
459
+ }
460
+ /**
461
+ * Resolves the EventSource constructor available in the current environment.
462
+ */
463
+ getEventSourceConstructor() {
464
+ if (this.customEventSource) {
465
+ return this.customEventSource;
466
+ }
467
+ if (typeof window !== "undefined" && window.EventSource) {
468
+ return window.EventSource;
469
+ }
470
+ if (typeof globalThis !== "undefined" && globalThis.EventSource) {
471
+ return globalThis.EventSource;
472
+ }
473
+ throw new Error(
474
+ '[RealtimeService] EventSource is not supported natively in this environment. Please pass an EventSource polyfill to ClientOptions (e.g. from the "eventsource" package).'
475
+ );
476
+ }
477
+ /**
478
+ * Connects to the SSE stream if not already connected.
479
+ * Returns the connected clientId.
480
+ */
481
+ async connect() {
482
+ if (this.clientId && this.eventSource) {
483
+ return this.clientId;
484
+ }
485
+ if (this.connectPromise) {
486
+ return this.connectPromise;
487
+ }
488
+ this.connectPromise = new Promise((resolve, reject) => {
489
+ try {
490
+ const EventSourceClass = this.getEventSourceConstructor();
491
+ const base = (this.client.baseUrl || "").replace(/\/+$/, "");
492
+ let url = `${base}/api/realtime`;
493
+ if (this.client.authStore.token) {
494
+ url += `?token=${encodeURIComponent(this.client.authStore.token)}`;
495
+ }
496
+ this.eventSource = new EventSourceClass(url);
497
+ const handleConnect = async (event) => {
498
+ try {
499
+ const data = JSON.parse(event.data);
500
+ this.clientId = data.clientId;
501
+ this.reconnectAttempts = 0;
502
+ await this.syncSubscriptions();
503
+ resolve(this.clientId);
504
+ } catch (err) {
505
+ reject(err);
506
+ }
507
+ };
508
+ this.eventSource.addEventListener("NS_CONNECT", handleConnect);
509
+ this.eventSource.addEventListener("NB_CONNECT", handleConnect);
510
+ this.eventSource.onerror = (err) => {
511
+ if (!this.clientId) {
512
+ reject(err);
513
+ }
514
+ this.handleDisconnect();
515
+ };
516
+ this.bindActiveTopics();
517
+ } catch (err) {
518
+ this.connectPromise = null;
519
+ reject(err);
520
+ }
521
+ }).finally(() => {
522
+ this.connectPromise = null;
523
+ });
524
+ return this.connectPromise;
525
+ }
526
+ /**
527
+ * Subscribes to realtime updates for a collection or record.
528
+ *
529
+ * @param topic - Collection name ('posts'), wildcard ('*'), or specific record ('posts/rec_123')
530
+ * @param callback - Event listener callback
531
+ * @returns Unsubscribe function
532
+ */
533
+ async subscribe(topic, callback) {
534
+ const cleanTopic = (topic || "*").trim();
535
+ if (!this.subscriptions.has(cleanTopic)) {
536
+ this.subscriptions.set(cleanTopic, /* @__PURE__ */ new Set());
537
+ }
538
+ this.subscriptions.get(cleanTopic).add(callback);
539
+ if (this.isConnected) {
540
+ this.bindTopicListener(cleanTopic);
541
+ await this.syncSubscriptions();
542
+ } else {
543
+ await this.connect();
544
+ }
545
+ return () => this.unsubscribe(cleanTopic, callback);
546
+ }
547
+ /**
548
+ * Unsubscribes from a topic or specific callback.
549
+ */
550
+ async unsubscribe(topic, callback) {
551
+ if (!topic) {
552
+ this.subscriptions.clear();
553
+ if (this.isConnected) {
554
+ await this.syncSubscriptions();
555
+ }
556
+ return;
557
+ }
558
+ const cleanTopic = topic.trim();
559
+ if (!this.subscriptions.has(cleanTopic)) {
560
+ return;
561
+ }
562
+ if (callback) {
563
+ this.subscriptions.get(cleanTopic).delete(callback);
564
+ if (this.subscriptions.get(cleanTopic).size === 0) {
565
+ this.subscriptions.delete(cleanTopic);
566
+ }
567
+ } else {
568
+ this.subscriptions.delete(cleanTopic);
569
+ }
570
+ if (this.isConnected) {
571
+ await this.syncSubscriptions();
572
+ }
573
+ }
574
+ /**
575
+ * Closes the SSE connection and clears state.
576
+ */
577
+ disconnect() {
578
+ if (this.reconnectTimeout) {
579
+ clearTimeout(this.reconnectTimeout);
580
+ this.reconnectTimeout = null;
581
+ }
582
+ if (this.eventSource) {
583
+ try {
584
+ this.eventSource.close();
585
+ } catch {
586
+ }
587
+ this.eventSource = null;
588
+ }
589
+ this.clientId = "";
590
+ this.registeredEventNames.clear();
591
+ }
592
+ /**
593
+ * Synchronizes active subscriptions with the backend via POST /api/realtime.
594
+ */
595
+ async syncSubscriptions() {
596
+ if (!this.clientId) return;
597
+ const topics = Array.from(this.subscriptions.keys());
598
+ try {
599
+ await this.client.send("/api/realtime", {
600
+ method: "POST",
601
+ body: {
602
+ clientId: this.clientId,
603
+ subscriptions: topics.length > 0 ? topics : ["__noop__"]
604
+ }
605
+ });
606
+ } catch (err) {
607
+ console.warn("[RealtimeService] Failed to sync subscriptions with server:", err);
608
+ }
609
+ }
610
+ /**
611
+ * Binds an EventSource listener for the collection corresponding to a topic.
612
+ */
613
+ bindTopicListener(topic) {
614
+ if (!this.eventSource) return;
615
+ const collectionName = topic.includes("/") ? topic.split("/")[0] : topic;
616
+ if (this.registeredEventNames.has(collectionName)) {
617
+ return;
618
+ }
619
+ this.registeredEventNames.add(collectionName);
620
+ this.eventSource.addEventListener(collectionName, (event) => {
621
+ try {
622
+ const payload = JSON.parse(event.data);
623
+ this.dispatchEvent(collectionName, payload);
624
+ } catch (err) {
625
+ console.error(`[RealtimeService] Error handling event for ${collectionName}:`, err);
626
+ }
627
+ });
628
+ }
629
+ bindActiveTopics() {
630
+ for (const topic of this.subscriptions.keys()) {
631
+ this.bindTopicListener(topic);
632
+ }
633
+ }
634
+ /**
635
+ * Dispatches received SSE events to matching subscribed listeners.
636
+ */
637
+ dispatchEvent(collectionName, event) {
638
+ const recordId = event.record?.id;
639
+ this.triggerListeners("*", event);
640
+ this.triggerListeners(collectionName, event);
641
+ this.triggerListeners(`${collectionName}/*`, event);
642
+ if (recordId) {
643
+ this.triggerListeners(`${collectionName}/${recordId}`, event);
644
+ }
645
+ }
646
+ triggerListeners(topic, event) {
647
+ const callbacks = this.subscriptions.get(topic);
648
+ if (!callbacks || callbacks.size === 0) return;
649
+ for (const cb of callbacks) {
650
+ try {
651
+ cb(event);
652
+ } catch (err) {
653
+ console.error(`[RealtimeService] Error in realtime listener for ${topic}:`, err);
654
+ }
655
+ }
656
+ }
657
+ /**
658
+ * Handles disconnections and schedules automatic reconnection.
659
+ */
660
+ handleDisconnect() {
661
+ if (this.reconnectTimeout) return;
662
+ this.disconnect();
663
+ const delay = Math.min(1e4, 500 * Math.pow(1.5, this.reconnectAttempts));
664
+ this.reconnectAttempts++;
665
+ this.reconnectTimeout = setTimeout(() => {
666
+ this.reconnectTimeout = null;
667
+ if (this.subscriptions.size > 0) {
668
+ this.connect().catch(() => {
669
+ });
670
+ }
671
+ }, delay);
672
+ }
673
+ };
674
+
675
+ // src/services/AdminService.ts
676
+ var AdminService = class extends BaseService {
677
+ /**
678
+ * Checks whether at least one superuser / admin account exists in NodeStack.
679
+ */
680
+ async hasAdmins() {
681
+ const res = await this.send("/api/admins/has-admins", {
682
+ method: "GET"
683
+ });
684
+ return Boolean(res?.hasAdmins);
685
+ }
686
+ /**
687
+ * Creates the initial superuser account during first-time setup.
688
+ * Automatically persists the returned admin token and model to authStore.
689
+ */
690
+ async createInitial(email, password) {
691
+ const res = await this.send("/api/admins/create-initial", {
692
+ method: "POST",
693
+ body: { email, password }
694
+ });
695
+ if (res?.token) {
696
+ this.client.authStore.save(res.token, res.admin);
697
+ }
698
+ return res;
699
+ }
700
+ /**
701
+ * Authenticates a superuser / admin using email and password.
702
+ * Automatically persists the returned admin token and model to authStore.
703
+ */
704
+ async authWithPassword(identity, password) {
705
+ const res = await this.send("/api/admins/auth-with-password", {
706
+ method: "POST",
707
+ body: { identity, password }
708
+ });
709
+ if (res?.token) {
710
+ this.client.authStore.save(res.token, res.admin);
711
+ }
712
+ return res;
713
+ }
714
+ /**
715
+ * Retrieves the currently authenticated superuser's profile.
716
+ */
717
+ async getMe() {
718
+ return this.send("/api/admins/me", {
719
+ method: "GET"
720
+ });
721
+ }
722
+ };
723
+
724
+ // src/services/CollectionService.ts
725
+ var CollectionService = class extends BaseService {
726
+ /**
727
+ * Retrieves all collection schema models.
728
+ */
729
+ async getList() {
730
+ const res = await this.send("/api/collections", {
731
+ method: "GET"
732
+ });
733
+ return Array.isArray(res) ? res : res?.items || [];
734
+ }
735
+ /**
736
+ * Retrieves a single collection schema by name or ID.
737
+ */
738
+ async getOne(nameOrId) {
739
+ return this.send(`/api/collections/${encodeURIComponent(nameOrId)}`, {
740
+ method: "GET"
741
+ });
742
+ }
743
+ /**
744
+ * Creates a new collection schema.
745
+ */
746
+ async create(data) {
747
+ return this.send("/api/collections", {
748
+ method: "POST",
749
+ body: data
750
+ });
751
+ }
752
+ /**
753
+ * Updates an existing collection schema.
754
+ */
755
+ async update(nameOrId, data) {
756
+ return this.send(`/api/collections/${encodeURIComponent(nameOrId)}`, {
757
+ method: "PATCH",
758
+ body: data
759
+ });
760
+ }
761
+ /**
762
+ * Deletes a collection schema and its corresponding SQLite table.
763
+ */
764
+ async delete(nameOrId) {
765
+ await this.send(`/api/collections/${encodeURIComponent(nameOrId)}`, {
766
+ method: "DELETE"
767
+ });
768
+ return true;
769
+ }
770
+ };
771
+
772
+ // src/Client.ts
773
+ var NodeStackClient = class {
774
+ baseUrl;
775
+ authStore;
776
+ files;
777
+ realtime;
778
+ admins;
779
+ collections;
780
+ customFetch;
781
+ defaultHeaders;
782
+ defaultTimeout;
783
+ recordServices = /* @__PURE__ */ new Map();
784
+ constructor(baseUrl = "/", options = {}) {
785
+ this.baseUrl = (baseUrl || "/").replace(/\/+$/, "");
786
+ this.authStore = options.authStore || new LocalAuthStore();
787
+ this.customFetch = options.fetch;
788
+ this.defaultHeaders = options.headers || {};
789
+ this.defaultTimeout = options.timeout || 12e4;
790
+ this.files = new FileService(this);
791
+ this.realtime = new RealtimeService(this, options.EventSource);
792
+ this.admins = new AdminService(this);
793
+ this.collections = new CollectionService(this);
794
+ }
795
+ collection(name) {
796
+ const colName = String(name);
797
+ let service = this.recordServices.get(colName);
798
+ if (!service) {
799
+ service = new RecordService(this, colName);
800
+ this.recordServices.set(colName, service);
801
+ }
802
+ return service;
803
+ }
804
+ /**
805
+ * Sends an HTTP request to NodeStack with automatic authentication,
806
+ * header injection, query serialization, and standardized error handling.
807
+ */
808
+ async send(path, options = {}) {
809
+ const fetchFn = this.customFetch || (typeof fetch !== "undefined" ? fetch : null);
810
+ if (!fetchFn) {
811
+ throw new Error(
812
+ "[NodeStackClient] fetch is not available in this environment. Please pass a custom fetch implementation to ClientOptions."
813
+ );
814
+ }
815
+ let url = path.startsWith("http://") || path.startsWith("https://") ? path : `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
816
+ if (options.query) {
817
+ const searchParams = new URLSearchParams();
818
+ for (const [key, val] of Object.entries(options.query)) {
819
+ if (val === void 0 || val === null) continue;
820
+ searchParams.append(key, String(val));
821
+ }
822
+ const queryStr = searchParams.toString();
823
+ if (queryStr) {
824
+ url += (url.includes("?") ? "&" : "?") + queryStr;
825
+ }
826
+ }
827
+ const headers = {
828
+ ...this.defaultHeaders,
829
+ ...options.headers || {}
830
+ };
831
+ if (this.authStore.token && !headers["Authorization"] && !headers["authorization"]) {
832
+ headers["Authorization"] = `Bearer ${this.authStore.token}`;
833
+ }
834
+ let body = options.body;
835
+ const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
836
+ const isBlob = typeof Blob !== "undefined" && body instanceof Blob;
837
+ if (body !== void 0 && body !== null) {
838
+ if (isFormData) {
839
+ delete headers["Content-Type"];
840
+ delete headers["content-type"];
841
+ } else if (isBlob || typeof body === "string") {
842
+ } else if (typeof body === "object") {
843
+ headers["Content-Type"] = "application/json";
844
+ body = JSON.stringify(body);
845
+ }
846
+ }
847
+ let signal = options.signal;
848
+ let timeoutId = null;
849
+ if (!signal && this.defaultTimeout > 0 && typeof AbortController !== "undefined") {
850
+ const controller = new AbortController();
851
+ signal = controller.signal;
852
+ timeoutId = setTimeout(() => controller.abort(), this.defaultTimeout);
853
+ }
854
+ try {
855
+ const response = await fetchFn(url, {
856
+ ...options,
857
+ headers,
858
+ body,
859
+ signal
860
+ });
861
+ if (timeoutId) {
862
+ clearTimeout(timeoutId);
863
+ }
864
+ if (response.status === 204) {
865
+ return null;
866
+ }
867
+ const contentType = response.headers.get("content-type") || "";
868
+ let data;
869
+ if (contentType.includes("application/json")) {
870
+ data = await response.json().catch(() => ({}));
871
+ } else {
872
+ data = await response.text();
873
+ }
874
+ if (!response.ok) {
875
+ throw new ClientResponseError({
876
+ url,
877
+ status: response.status,
878
+ data,
879
+ message: typeof data === "object" && data?.message ? data.message : void 0
880
+ });
881
+ }
882
+ return data;
883
+ } catch (err) {
884
+ if (timeoutId) {
885
+ clearTimeout(timeoutId);
886
+ }
887
+ if (err instanceof ClientResponseError) {
888
+ throw err;
889
+ }
890
+ const isAbort = err?.name === "AbortError" || signal && signal.aborted || Boolean(options.signal && options.signal.aborted);
891
+ throw new ClientResponseError({
892
+ url,
893
+ status: isAbort ? 0 : 0,
894
+ isAbort,
895
+ originalError: err,
896
+ message: isAbort ? "Request was aborted" : err?.message || "Network request failed"
897
+ });
898
+ }
899
+ }
900
+ /**
901
+ * Checks backend system health.
902
+ */
903
+ async health() {
904
+ return this.send("/api/health", { method: "GET" });
905
+ }
906
+ /**
907
+ * Retrieves server runtime settings.
908
+ */
909
+ async settings() {
910
+ return this.send("/api/settings", { method: "GET" });
911
+ }
912
+ /**
913
+ * Fetches request logs (Admin only).
914
+ */
915
+ async logs(page = 1, perPage = 50) {
916
+ return this.send("/api/logs", {
917
+ method: "GET",
918
+ query: { page, perPage }
919
+ });
920
+ }
921
+ /**
922
+ * Fetches system metrics & analytics (Admin only).
923
+ */
924
+ async metrics() {
925
+ return this.send("/api/metrics", { method: "GET" });
926
+ }
927
+ };
928
+
929
+ // src/auth/MemoryAuthStore.ts
930
+ var MemoryAuthStore = class extends BaseAuthStore {
931
+ // Inherits default in-memory behavior of BaseAuthStore
932
+ };
933
+ export {
934
+ AdminService,
935
+ BaseAuthStore,
936
+ BaseService,
937
+ ClientResponseError,
938
+ CollectionService,
939
+ FileService,
940
+ LocalAuthStore,
941
+ MemoryAuthStore,
942
+ NodeStackClient,
943
+ RealtimeService,
944
+ RecordService,
945
+ decodeJwtPayload,
946
+ NodeStackClient as default
947
+ };
948
+ //# sourceMappingURL=index.mjs.map