dolphin-client 1.0.4 → 1.0.6

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.js CHANGED
@@ -22,7 +22,21 @@ var APIHandler = class {
22
22
  target.requestDirect = (method, path, body, options) => {
23
23
  return this.requestDirect(method, path, body, options);
24
24
  };
25
- const methods = ["get", "post", "put", "patch", "del", "request", "requestDirect"];
25
+ target._findCSRFToken = () => this._findCSRFToken();
26
+ target._resolveBaseUrl = (path) => this._resolveBaseUrl(path);
27
+ target._normalizeValidationErrors = (errData) => this._normalizeValidationErrors(errData);
28
+ const methods = [
29
+ "get",
30
+ "post",
31
+ "put",
32
+ "patch",
33
+ "del",
34
+ "request",
35
+ "requestDirect",
36
+ "_findCSRFToken",
37
+ "_resolveBaseUrl",
38
+ "_normalizeValidationErrors"
39
+ ];
26
40
  return new Proxy(target, {
27
41
  get: (t, prop) => {
28
42
  if (typeof prop === "string" && !methods.includes(prop)) {
@@ -32,6 +46,108 @@ var APIHandler = class {
32
46
  }
33
47
  });
34
48
  }
49
+ /**
50
+ * Attempts to find a CSRF token in the document (meta tags, forms, or cookies).
51
+ * Works for Laravel, CakePHP, WordPress, Express, NestJS, etc.
52
+ * @private
53
+ */
54
+ _findCSRFToken() {
55
+ if (typeof document === "undefined") return null;
56
+ const metaNames = ["csrf-token", "_csrf", "xsrf-token", "csrf_token"];
57
+ for (const name of metaNames) {
58
+ const metaEl = document.querySelector(`meta[name="${name}"], meta[content][name$="${name}"]`);
59
+ if (metaEl) {
60
+ const token = metaEl.getAttribute("content");
61
+ if (token) return token;
62
+ }
63
+ }
64
+ const inputNames = ["_csrfToken", "_token", "_csrf", "csrf_token"];
65
+ for (const name of inputNames) {
66
+ const inputEl = document.querySelector(`input[type="hidden"][name="${name}"]`);
67
+ if (inputEl && inputEl.value) return inputEl.value;
68
+ }
69
+ const cookies = ["csrfToken", "XSRF-TOKEN", "_csrf", "csrf_token"];
70
+ for (const name of cookies) {
71
+ const match = document.cookie.match(new RegExp("(^|;\\s*)" + name + "=([^;]*)"));
72
+ if (match) return decodeURIComponent(match[2]);
73
+ }
74
+ const wpNonce = typeof window !== "undefined" && window.wpApiSettings?.nonce;
75
+ if (wpNonce) return wpNonce;
76
+ return null;
77
+ }
78
+ /**
79
+ * Dynamically resolves the Base Path/URL from `<base href="...">` or subfolders.
80
+ * @private
81
+ */
82
+ _resolveBaseUrl(path) {
83
+ if (path.startsWith("http://") || path.startsWith("https://")) {
84
+ return path;
85
+ }
86
+ let baseUrl = this.client.httpUrl;
87
+ if (typeof document !== "undefined") {
88
+ const baseEl = document.querySelector("base[href]");
89
+ if (baseEl) {
90
+ const href = baseEl.getAttribute("href") || "";
91
+ if (href && href !== "/") {
92
+ const cleanHref = href.endsWith("/") ? href.slice(0, -1) : href;
93
+ baseUrl = `${this.client.httpUrl}${cleanHref.startsWith("/") ? cleanHref : "/" + cleanHref}`;
94
+ }
95
+ } else {
96
+ const metaBase = document.querySelector('meta[name="base-path"]');
97
+ if (metaBase) {
98
+ const content = metaBase.getAttribute("content") || "";
99
+ if (content && content !== "/") {
100
+ const cleanContent = content.endsWith("/") ? content.slice(0, -1) : content;
101
+ baseUrl = `${this.client.httpUrl}${cleanContent.startsWith("/") ? cleanContent : "/" + cleanContent}`;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ const cleanPath = path.startsWith("/") ? path : "/" + path;
107
+ return `${baseUrl}${cleanPath}`;
108
+ }
109
+ /**
110
+ * Normalizes backend validation errors from major PHP and Node.js frameworks
111
+ * into a unified { [field]: message } object.
112
+ * @private
113
+ */
114
+ _normalizeValidationErrors(errData) {
115
+ const normalized = {};
116
+ if (!errData || typeof errData !== "object") return normalized;
117
+ const errors = errData.errors || errData.validationErrors || errData;
118
+ if (Array.isArray(errors)) {
119
+ for (const err of errors) {
120
+ if (err && typeof err === "object") {
121
+ const field = err.path || err.param || err.field || err.property;
122
+ const msg = err.msg || err.message || err.error;
123
+ if (field && msg) {
124
+ normalized[field] = Array.isArray(msg) ? msg[0] : msg;
125
+ }
126
+ }
127
+ }
128
+ return normalized;
129
+ }
130
+ if (typeof errors === "object" && errors !== null) {
131
+ for (const key in errors) {
132
+ const val = errors[key];
133
+ if (!val) continue;
134
+ if (Array.isArray(val)) {
135
+ if (val.length > 0) {
136
+ normalized[key] = String(val[0]);
137
+ }
138
+ } else if (typeof val === "object") {
139
+ const innerKeys = Object.keys(val);
140
+ if (innerKeys.length > 0) {
141
+ const firstInnerKey = innerKeys[0];
142
+ normalized[key] = String(val[firstInnerKey]);
143
+ }
144
+ } else {
145
+ normalized[key] = String(val);
146
+ }
147
+ }
148
+ }
149
+ return normalized;
150
+ }
35
151
  /**
36
152
  * Intercept request for offline-first caching and queuing.
37
153
  */
@@ -82,30 +198,68 @@ var APIHandler = class {
82
198
  */
83
199
  async requestDirect(method, path, body = null, options = {}) {
84
200
  const _isRetry = options._isRetry === true;
85
- const url = path.startsWith("http://") || path.startsWith("https://") ? path : `${this.client.httpUrl}${path.startsWith("/") ? path : "/" + path}`;
201
+ let finalMethod = method.toUpperCase();
202
+ let finalBody = body;
203
+ const headers = {
204
+ "Content-Type": "application/json",
205
+ ...options.headers || {}
206
+ };
207
+ if (["PUT", "PATCH", "DELETE"].includes(finalMethod)) {
208
+ if (this.client.options.methodSpoofing || options.methodSpoofing) {
209
+ headers["X-HTTP-Method-Override"] = finalMethod;
210
+ if (finalBody instanceof FormData) {
211
+ finalBody.append("_method", finalMethod);
212
+ } else if (finalBody && typeof finalBody === "object") {
213
+ finalBody = {
214
+ ...finalBody,
215
+ _method: finalMethod
216
+ };
217
+ } else if (!finalBody) {
218
+ finalBody = { _method: finalMethod };
219
+ }
220
+ finalMethod = "POST";
221
+ }
222
+ }
223
+ const url = this._resolveBaseUrl(path);
86
224
  if (this.client.options.debug) {
87
- console.log(`%c\u{1F680} [Dolphin API Request]:`, "color: #3b82f6; font-weight: bold;", method.toUpperCase(), path, body || "");
225
+ console.log(`%c\u{1F680} [Dolphin API Request]:`, "color: #3b82f6; font-weight: bold;", method.toUpperCase(), path, finalBody || "");
88
226
  }
89
227
  const controller = new AbortController();
90
228
  const timeoutId = setTimeout(
91
229
  () => controller.abort(),
92
230
  this.client.options.timeout
93
231
  );
94
- const headers = {
95
- "Content-Type": "application/json",
96
- ...options.headers || {}
97
- };
98
232
  if (this.client.accessToken) {
99
233
  headers["Authorization"] = `Bearer ${this.client.accessToken}`;
100
234
  }
235
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase())) {
236
+ const csrfToken = this._findCSRFToken();
237
+ if (csrfToken) {
238
+ headers["X-CSRF-Token"] = csrfToken;
239
+ headers["X-XSRF-TOKEN"] = csrfToken;
240
+ headers["X-CSRFToken"] = csrfToken;
241
+ headers["X-WP-Nonce"] = csrfToken;
242
+ if (finalBody && typeof finalBody === "object") {
243
+ if (!finalBody._csrfToken && !finalBody._token && !finalBody._csrf) {
244
+ finalBody = {
245
+ ...finalBody,
246
+ _csrfToken: csrfToken,
247
+ _token: csrfToken,
248
+ _csrf: csrfToken
249
+ };
250
+ }
251
+ }
252
+ }
253
+ }
101
254
  const fetchOptions = { ...options };
102
255
  delete fetchOptions._isRetry;
256
+ delete fetchOptions.methodSpoofing;
103
257
  try {
104
258
  const response = await fetch(url, {
105
- method,
259
+ method: finalMethod,
106
260
  headers,
107
261
  signal: controller.signal,
108
- ...body ? { body: JSON.stringify(body) } : {},
262
+ ...finalBody ? { body: JSON.stringify(finalBody) } : {},
109
263
  ...fetchOptions
110
264
  });
111
265
  clearTimeout(timeoutId);
@@ -137,6 +291,14 @@ var APIHandler = class {
137
291
  if (this.client.options.debug) {
138
292
  console.error(`%c\u274C [Dolphin API Error]:`, "color: #ef4444; font-weight: bold;", method.toUpperCase(), path, err);
139
293
  }
294
+ if (err && typeof err === "object" && err.data) {
295
+ const normErrors = this._normalizeValidationErrors(err.data);
296
+ if (Object.keys(normErrors).length > 0) {
297
+ for (const field in normErrors) {
298
+ this.client.publish(`errors/${field}`, normErrors[field]);
299
+ }
300
+ }
301
+ }
140
302
  if (err.name === "AbortError") {
141
303
  throw { status: 408, data: { error: "Request timed out" } };
142
304
  }
@@ -270,12 +432,15 @@ var DolphinStore = class {
270
432
  data;
271
433
  listeners;
272
434
  subscribed;
435
+ /** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions (was: subscriptions never removed) */
436
+ _unsubscribers;
273
437
  /** @param {DolphinClient} client */
274
438
  constructor(client) {
275
439
  this.client = client;
276
440
  this.data = /* @__PURE__ */ new Map();
277
441
  this.listeners = /* @__PURE__ */ new Set();
278
442
  this.subscribed = /* @__PURE__ */ new Set();
443
+ this._unsubscribers = /* @__PURE__ */ new Map();
279
444
  return new Proxy(this, {
280
445
  get: (target, prop) => {
281
446
  if (prop in target) return target[prop];
@@ -327,9 +492,12 @@ var DolphinStore = class {
327
492
  state.error = null;
328
493
  this._applyTransform(state);
329
494
  if (!this.subscribed.has(name)) {
330
- this.client.subscribe(`db:sync/${name.toLowerCase()}`, (update) => {
495
+ const unsubscribe = () => this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
496
+ const updateHandler = (update) => {
331
497
  this._handleRemoteUpdate(name, update);
332
- });
498
+ };
499
+ this.client.subscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
500
+ this._unsubscribers.set(name, unsubscribe);
333
501
  this.subscribed.add(name);
334
502
  }
335
503
  } catch (e) {
@@ -386,6 +554,22 @@ var DolphinStore = class {
386
554
  _notify() {
387
555
  this.listeners.forEach((l) => l());
388
556
  }
557
+ /**
558
+ * Clean up all WebSocket subscriptions and listeners.
559
+ * Call this when the store is no longer needed to prevent resource leaks.
560
+ */
561
+ destroy() {
562
+ this._unsubscribers.forEach((unsub) => {
563
+ try {
564
+ unsub();
565
+ } catch {
566
+ }
567
+ });
568
+ this._unsubscribers.clear();
569
+ this.subscribed.clear();
570
+ this.listeners.clear();
571
+ this.data.clear();
572
+ }
389
573
  };
390
574
 
391
575
  // src/core.ts
@@ -405,6 +589,8 @@ var DolphinClient = class {
405
589
  fileHandlers;
406
590
  _offlineQueue;
407
591
  reconnectAttempts;
592
+ /** @fix: Store timer ID so disconnect() can cancel pending reconnects (was: memory/logic leak) */
593
+ _reconnectTimer;
408
594
  _attachedListeners;
409
595
  constructor(url = "", deviceId = "", options = {}) {
410
596
  if (!url && typeof window !== "undefined") url = window.location.host;
@@ -414,7 +600,7 @@ var DolphinClient = class {
414
600
  else if (typeof window !== "undefined") protocol = window.location.protocol;
415
601
  this.host = (url || "localhost").replace(/\/$/, "").replace(/^https?:\/\//, "");
416
602
  this.httpUrl = `${protocol}//${this.host}`;
417
- this.deviceId = deviceId || "web_" + Math.random().toString(36).substr(2, 8);
603
+ this.deviceId = deviceId || (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? "web_" + crypto.randomUUID().replace(/-/g, "").slice(0, 8) : "web_" + Math.random().toString(36).slice(2, 10));
418
604
  this.options = {
419
605
  timeout: 15e3,
420
606
  chunkSize: 65536,
@@ -422,6 +608,9 @@ var DolphinClient = class {
422
608
  maxReconnect: 5,
423
609
  autoRefreshToken: true,
424
610
  debug: false,
611
+ methodSpoofing: false,
612
+ routerViewport: "main, #viewport, body",
613
+ routerTransitions: true,
425
614
  ...options
426
615
  };
427
616
  this.socket = null;
@@ -441,6 +630,7 @@ var DolphinClient = class {
441
630
  this.fileHandlers = /* @__PURE__ */ new Set();
442
631
  this._offlineQueue = [];
443
632
  this.reconnectAttempts = 0;
633
+ this._reconnectTimer = null;
444
634
  this._attachedListeners = [];
445
635
  if (typeof window !== "undefined" && typeof this._initDOMBinding === "function") {
446
636
  this._initDOMBinding();
@@ -469,6 +659,9 @@ var DolphinClient = class {
469
659
  // ── WebSocket ─────────────────────────────────────────────────────────────
470
660
  /** Connect to the Dolphin realtime server */
471
661
  async connect() {
662
+ if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
663
+ return Promise.resolve();
664
+ }
472
665
  return new Promise((resolve, reject) => {
473
666
  const protocol = this.httpUrl.startsWith("https") ? "wss:" : "ws:";
474
667
  const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
@@ -493,11 +686,18 @@ var DolphinClient = class {
493
686
  }
494
687
  /** Disconnect cleanly */
495
688
  disconnect() {
689
+ if (this._reconnectTimer !== null) {
690
+ clearTimeout(this._reconnectTimer);
691
+ this._reconnectTimer = null;
692
+ }
496
693
  if (this.socket) {
497
694
  this.socket.onclose = null;
498
695
  this.socket.close();
499
696
  this.socket = null;
500
697
  }
698
+ if (typeof this._collabCleanup === "function") {
699
+ this._collabCleanup();
700
+ }
501
701
  this.cleanupDomListeners();
502
702
  }
503
703
  /** @private */
@@ -585,8 +785,11 @@ var DolphinClient = class {
585
785
  this.reconnectAttempts++;
586
786
  const delay = Math.pow(2, this.reconnectAttempts) * 1e3;
587
787
  console.log(`[Dolphin] Reconnecting in ${delay / 1e3}s (attempt ${this.reconnectAttempts})...`);
588
- setTimeout(() => this.connect().catch(() => {
589
- }), delay);
788
+ this._reconnectTimer = setTimeout(() => {
789
+ this._reconnectTimer = null;
790
+ this.connect().catch(() => {
791
+ });
792
+ }, delay);
590
793
  } else {
591
794
  console.error("[Dolphin] Max reconnect attempts reached.");
592
795
  }
@@ -764,6 +967,7 @@ var DolphinClient = class {
764
967
 
765
968
  // src/dom.ts
766
969
  function attachDOMBinding(clientProto) {
970
+ const componentPromiseCache = /* @__PURE__ */ new Map();
767
971
  function escapeRegExp(str) {
768
972
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
769
973
  }
@@ -1010,7 +1214,9 @@ function attachDOMBinding(clientProto) {
1010
1214
  const scheduleFn = typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : (cb) => setTimeout(cb, 0);
1011
1215
  scheduleFn(() => {
1012
1216
  pendingUpdates.forEach((html, el) => {
1013
- patchDOM(el, html);
1217
+ if (el.isConnected !== false) {
1218
+ patchDOM(el, html);
1219
+ }
1014
1220
  });
1015
1221
  pendingUpdates.clear();
1016
1222
  rafScheduled = false;
@@ -1149,7 +1355,7 @@ function attachDOMBinding(clientProto) {
1149
1355
  if (this._domInitialized) return;
1150
1356
  this._domInitialized = true;
1151
1357
  const PUSH_EVENTS = ["input", "change", "keyup", "paste", "blur"];
1152
- const debounceTimers = /* @__PURE__ */ new Map();
1358
+ const debounceTimers = /* @__PURE__ */ new WeakMap();
1153
1359
  PUSH_EVENTS.forEach((evtName) => {
1154
1360
  this.addDomListener(document, evtName, (e) => {
1155
1361
  if (!e.target || !e.target.getAttribute) return;
@@ -1202,6 +1408,14 @@ function attachDOMBinding(clientProto) {
1202
1408
  const rtTopic = e.target.getAttribute("data-rt-submit");
1203
1409
  const apiTarget = e.target.getAttribute("data-api-submit");
1204
1410
  if (rtTopic || apiTarget) {
1411
+ const formInputs = e.target.querySelectorAll("[name]");
1412
+ formInputs.forEach((inputEl) => {
1413
+ const name = inputEl.name;
1414
+ if (name) {
1415
+ this.publish(`errors/${name}`, "");
1416
+ inputEl.classList.remove("invalid");
1417
+ }
1418
+ });
1205
1419
  const validatedInputs = e.target.querySelectorAll("[data-rt-validate]");
1206
1420
  let formIsValid = true;
1207
1421
  if (validatedInputs.length > 0 && typeof this.validateField === "function") {
@@ -1215,9 +1429,6 @@ function attachDOMBinding(clientProto) {
1215
1429
  formIsValid = false;
1216
1430
  inputEl.classList.add("invalid");
1217
1431
  this.publish(`errors/${name}`, errorMsg);
1218
- } else {
1219
- inputEl.classList.remove("invalid");
1220
- this.publish(`errors/${name}`, "");
1221
1432
  }
1222
1433
  }
1223
1434
  });
@@ -1245,8 +1456,11 @@ function attachDOMBinding(clientProto) {
1245
1456
  resolvedTarget = resolvedTarget.replace(new RegExp(`\\{\\{${escapedK}\\}\\}`, "g"), parentCtx[k] !== void 0 && parentCtx[k] !== null ? parentCtx[k] : "");
1246
1457
  }
1247
1458
  const parts = resolvedTarget.trim().split(" ");
1248
- const method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
1459
+ let method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
1249
1460
  const path = parts.length > 1 ? parts[1] : parts[0];
1461
+ if (data._method) {
1462
+ method = String(data._method).toUpperCase();
1463
+ }
1250
1464
  try {
1251
1465
  const result = await this.api.request(method, path, data);
1252
1466
  const resultBind = e.target.getAttribute("data-api-result");
@@ -1333,6 +1547,8 @@ function attachDOMBinding(clientProto) {
1333
1547
  });
1334
1548
  this._scanAndFetchAPIBinds();
1335
1549
  this._scanStoreBinds();
1550
+ this._resolveImports();
1551
+ this._initSPARouter();
1336
1552
  };
1337
1553
  clientProto._scanAndFetchAPIBinds = async function() {
1338
1554
  if (typeof document === "undefined") return;
@@ -1340,6 +1556,10 @@ function attachDOMBinding(clientProto) {
1340
1556
  for (const el of Array.from(elements)) {
1341
1557
  const path = el.getAttribute("data-api-get");
1342
1558
  if (!path) continue;
1559
+ if (typeof el.hasAttribute === "function" && el.hasAttribute("data-api-initialized")) continue;
1560
+ if (typeof el.setAttribute === "function") {
1561
+ el.setAttribute("data-api-initialized", "true");
1562
+ }
1343
1563
  try {
1344
1564
  const result = await this.api.get(path);
1345
1565
  const apiStore = el.getAttribute("data-api-store");
@@ -1355,20 +1575,22 @@ function attachDOMBinding(clientProto) {
1355
1575
  } else if (!apiStore) {
1356
1576
  const template = resolveTemplate(el);
1357
1577
  if (template && typeof result === "object" && result !== null) {
1358
- if (Array.isArray(result)) {
1578
+ const processedResult = this._applyDeclarativeDirectives(el, result);
1579
+ if (Array.isArray(processedResult)) {
1359
1580
  let combinedHTML = "";
1360
- for (const item of result) {
1581
+ for (const item of processedResult) {
1361
1582
  combinedHTML += renderTemplate(template, item);
1362
1583
  }
1363
1584
  scheduleDOMUpdate(el, combinedHTML);
1364
1585
  } else {
1365
- scheduleDOMUpdate(el, renderTemplate(template, result));
1586
+ scheduleDOMUpdate(el, renderTemplate(template, processedResult));
1366
1587
  }
1367
1588
  } else {
1368
1589
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1369
1590
  el.value = typeof result === "object" ? result.value !== void 0 ? result.value : "" : result;
1370
1591
  } else {
1371
- el.innerHTML = typeof result === "object" ? result.html || result.text || JSON.stringify(result) : result;
1592
+ const rawHTML = typeof result === "object" ? result.html || result.text || JSON.stringify(result) : String(result);
1593
+ el.innerHTML = sanitizeHTML(rawHTML);
1372
1594
  }
1373
1595
  }
1374
1596
  }
@@ -1377,21 +1599,139 @@ function attachDOMBinding(clientProto) {
1377
1599
  }
1378
1600
  }
1379
1601
  };
1602
+ clientProto._applyDeclarativeDirectives = function(el, payload) {
1603
+ let processedPayload = payload;
1604
+ if (typeof payload === "object" && payload !== null) {
1605
+ const applyFilterSearchSort = (arr) => {
1606
+ let result = [...arr];
1607
+ const filterAttr = el.getAttribute("data-rt-filter");
1608
+ if (filterAttr) {
1609
+ const parts = filterAttr.split("==");
1610
+ if (parts.length === 2) {
1611
+ const itemProp = parts[0].trim();
1612
+ const storeExpr = parts[1].trim();
1613
+ let filterVal = void 0;
1614
+ const storeParts = storeExpr.split(".");
1615
+ if (storeParts.length === 2) {
1616
+ filterVal = this.getStoreState(storeParts[0], storeParts[1]);
1617
+ } else {
1618
+ filterVal = payload[storeExpr] !== void 0 ? payload[storeExpr] : this.getStoreState("app", storeExpr);
1619
+ }
1620
+ if (filterVal !== void 0 && filterVal !== null && filterVal !== "") {
1621
+ result = result.filter((item) => item[itemProp] === filterVal);
1622
+ }
1623
+ }
1624
+ }
1625
+ const searchAttr = el.getAttribute("data-rt-search");
1626
+ if (searchAttr) {
1627
+ const parts = searchAttr.split("==");
1628
+ if (parts.length === 2) {
1629
+ const itemProp = parts[0].trim();
1630
+ const storeExpr = parts[1].trim();
1631
+ let searchVal = void 0;
1632
+ const storeParts = storeExpr.split(".");
1633
+ if (storeParts.length === 2) {
1634
+ searchVal = this.getStoreState(storeParts[0], storeParts[1]);
1635
+ } else {
1636
+ searchVal = payload[storeExpr] !== void 0 ? payload[storeExpr] : this.getStoreState("app", storeExpr);
1637
+ }
1638
+ if (searchVal !== void 0 && searchVal !== null && searchVal !== "") {
1639
+ const query = String(searchVal).toLowerCase();
1640
+ result = result.filter((item) => {
1641
+ const val = item[itemProp];
1642
+ return val !== void 0 && val !== null && String(val).toLowerCase().includes(query);
1643
+ });
1644
+ }
1645
+ }
1646
+ }
1647
+ const sortAttr = el.getAttribute("data-rt-sort");
1648
+ if (sortAttr) {
1649
+ let sortByVal = void 0;
1650
+ const storeParts = sortAttr.split(".");
1651
+ if (storeParts.length === 2) {
1652
+ sortByVal = this.getStoreState(storeParts[0], storeParts[1]);
1653
+ } else {
1654
+ sortByVal = payload[sortAttr] !== void 0 ? payload[sortAttr] : this.getStoreState("app", sortAttr);
1655
+ }
1656
+ if (sortByVal && sortByVal !== "") {
1657
+ if (sortByVal === "popular") {
1658
+ result.sort((a, b) => {
1659
+ const rateA = a.rating?.rate || a.rate || 0;
1660
+ const rateB = b.rating?.rate || b.rate || 0;
1661
+ return rateB - rateA;
1662
+ });
1663
+ } else {
1664
+ let field = "";
1665
+ let direction = "asc";
1666
+ if (sortByVal.endsWith("-low") || sortByVal.endsWith("-asc")) {
1667
+ field = sortByVal.replace("-low", "").replace("-asc", "");
1668
+ direction = "asc";
1669
+ } else if (sortByVal.endsWith("-high") || sortByVal.endsWith("-desc")) {
1670
+ field = sortByVal.replace("-high", "").replace("-desc", "");
1671
+ direction = "desc";
1672
+ }
1673
+ if (field) {
1674
+ result.sort((a, b) => {
1675
+ const resolveVal = (obj, path) => {
1676
+ return path.split(".").reduce((acc, part) => acc && acc[part], obj);
1677
+ };
1678
+ let valA = resolveVal(a, field);
1679
+ let valB = resolveVal(b, field);
1680
+ if (valA === void 0) valA = a[field];
1681
+ if (valB === void 0) valB = b[field];
1682
+ if (typeof valA === "string" && typeof valB === "string") {
1683
+ return direction === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA);
1684
+ }
1685
+ const numA = Number(valA);
1686
+ const numB = Number(valB);
1687
+ if (!isNaN(numA) && !isNaN(numB)) {
1688
+ return direction === "asc" ? numA - numB : numB - numA;
1689
+ }
1690
+ return 0;
1691
+ });
1692
+ }
1693
+ }
1694
+ }
1695
+ }
1696
+ return result;
1697
+ };
1698
+ if (Array.isArray(payload)) {
1699
+ processedPayload = applyFilterSearchSort(payload);
1700
+ } else {
1701
+ let foundArrayKey = "";
1702
+ for (const key in payload) {
1703
+ if (Array.isArray(payload[key])) {
1704
+ foundArrayKey = key;
1705
+ break;
1706
+ }
1707
+ }
1708
+ if (foundArrayKey) {
1709
+ const processedArray = applyFilterSearchSort(payload[foundArrayKey]);
1710
+ processedPayload = {
1711
+ ...payload,
1712
+ [foundArrayKey]: processedArray
1713
+ };
1714
+ }
1715
+ }
1716
+ }
1717
+ return processedPayload;
1718
+ };
1380
1719
  clientProto._updateDOM = function(topic, payload) {
1381
1720
  if (typeof document === "undefined") return;
1382
1721
  const elements = document.querySelectorAll(`[data-rt-bind="${topic}"]`);
1383
1722
  elements.forEach((el) => {
1384
- if (el.getAttribute("data-rt-type") === "context" && typeof payload === "object" && payload !== null) {
1385
- el._rtContext = payload;
1723
+ const processedPayload = this._applyDeclarativeDirectives(el, payload);
1724
+ if (el.getAttribute("data-rt-type") === "context" && typeof processedPayload === "object" && processedPayload !== null) {
1725
+ el._rtContext = processedPayload;
1386
1726
  const processNode = (node) => {
1387
1727
  if (node.hasAttribute("data-rt-text")) {
1388
1728
  const key = node.getAttribute("data-rt-text");
1389
- if (key && payload[key] !== void 0 && payload[key] !== null) node.textContent = payload[key];
1729
+ if (key && processedPayload[key] !== void 0 && processedPayload[key] !== null) node.textContent = processedPayload[key];
1390
1730
  }
1391
1731
  if (node.hasAttribute("data-rt-html")) {
1392
1732
  const key = node.getAttribute("data-rt-html");
1393
- if (key && payload[key] !== void 0 && payload[key] !== null) {
1394
- node.innerHTML = sanitizeHTML(payload[key]);
1733
+ if (key && processedPayload[key] !== void 0 && processedPayload[key] !== null) {
1734
+ node.innerHTML = sanitizeHTML(processedPayload[key]);
1395
1735
  }
1396
1736
  }
1397
1737
  if (node.hasAttribute("data-rt-attr")) {
@@ -1402,8 +1742,8 @@ function attachDOMBinding(clientProto) {
1402
1742
  if (parts.length === 2) {
1403
1743
  const attrName = parts[0].trim();
1404
1744
  const key = parts[1].trim();
1405
- if (attrName && key && payload[key] !== void 0 && payload[key] !== null) {
1406
- node.setAttribute(attrName, payload[key]);
1745
+ if (attrName && key && processedPayload[key] !== void 0 && processedPayload[key] !== null) {
1746
+ node.setAttribute(attrName, processedPayload[key]);
1407
1747
  }
1408
1748
  }
1409
1749
  });
@@ -1418,7 +1758,7 @@ function attachDOMBinding(clientProto) {
1418
1758
  const className = parts[0].trim();
1419
1759
  const key = parts[1].trim();
1420
1760
  const classNames = className.split(/\s+/).filter(Boolean);
1421
- if (payload[key]) {
1761
+ if (processedPayload[key]) {
1422
1762
  classNames.forEach((c) => node.classList.add(c));
1423
1763
  } else {
1424
1764
  classNames.forEach((c) => node.classList.remove(c));
@@ -1430,7 +1770,7 @@ function attachDOMBinding(clientProto) {
1430
1770
  if (node.hasAttribute("data-rt-if")) {
1431
1771
  const key = node.getAttribute("data-rt-if");
1432
1772
  if (key) {
1433
- if (payload[key]) {
1773
+ if (processedPayload[key]) {
1434
1774
  node.style.display = "";
1435
1775
  } else {
1436
1776
  node.style.display = "none";
@@ -1440,7 +1780,7 @@ function attachDOMBinding(clientProto) {
1440
1780
  if (node.hasAttribute("data-rt-hide")) {
1441
1781
  const key = node.getAttribute("data-rt-hide");
1442
1782
  if (key) {
1443
- if (payload[key]) {
1783
+ if (processedPayload[key]) {
1444
1784
  node.style.display = "none";
1445
1785
  } else {
1446
1786
  node.style.display = "";
@@ -1453,25 +1793,172 @@ function attachDOMBinding(clientProto) {
1453
1793
  return;
1454
1794
  }
1455
1795
  const template = resolveTemplate(el);
1456
- if (template && typeof payload === "object" && payload !== null) {
1457
- if (Array.isArray(payload)) {
1796
+ if (template && typeof processedPayload === "object" && processedPayload !== null) {
1797
+ if (Array.isArray(processedPayload)) {
1458
1798
  let combinedHTML = "";
1459
- for (const item of payload) {
1799
+ for (const item of processedPayload) {
1460
1800
  combinedHTML += renderTemplate(template, item);
1461
1801
  }
1462
1802
  scheduleDOMUpdate(el, combinedHTML);
1463
1803
  } else {
1464
- scheduleDOMUpdate(el, renderTemplate(template, payload));
1804
+ scheduleDOMUpdate(el, renderTemplate(template, processedPayload));
1465
1805
  }
1466
1806
  return;
1467
1807
  }
1468
1808
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1469
- el.value = typeof payload === "object" ? payload.value !== void 0 ? payload.value : "" : payload;
1809
+ el.value = typeof processedPayload === "object" ? processedPayload.value !== void 0 ? processedPayload.value : "" : processedPayload;
1810
+ } else if (template) {
1811
+ el.innerHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1470
1812
  } else {
1471
- el.innerHTML = typeof payload === "object" ? payload.html || payload.text || JSON.stringify(payload) : payload;
1813
+ const rawHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1814
+ el.innerHTML = sanitizeHTML(rawHTML);
1472
1815
  }
1473
1816
  });
1474
1817
  };
1818
+ clientProto._resolveImports = async function(container) {
1819
+ if (typeof document === "undefined") return;
1820
+ const root = container || document.body || document;
1821
+ if (!root || typeof root.querySelectorAll !== "function") return;
1822
+ const elements = root.querySelectorAll("[data-import]");
1823
+ if (elements.length === 0) return;
1824
+ const resolveNode = async (el, resolvingSet) => {
1825
+ const src = el.getAttribute("data-import");
1826
+ if (!src) return;
1827
+ if (resolvingSet.has(src)) {
1828
+ console.warn(`[Dolphin Component Warning]: Circular import detected for "${src}". Skipping resolving.`);
1829
+ el.innerHTML = `<span style="color:red;font-weight:bold;">Circular import: ${src}</span>`;
1830
+ return;
1831
+ }
1832
+ resolvingSet.add(src);
1833
+ let promise = componentPromiseCache.get(src);
1834
+ if (!promise) {
1835
+ promise = fetch(src).then((res) => {
1836
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1837
+ return res.text();
1838
+ });
1839
+ promise.catch(() => componentPromiseCache.delete(src));
1840
+ componentPromiseCache.set(src, promise);
1841
+ }
1842
+ let content = "";
1843
+ try {
1844
+ content = await promise;
1845
+ } catch (err) {
1846
+ console.error(`[Dolphin Component Error]: Failed to fetch component "${src}":`, err);
1847
+ content = `<span style="color:red;font-weight:bold;">Failed to import ${src}</span>`;
1848
+ }
1849
+ el.innerHTML = sanitizeHTML(content);
1850
+ el.removeAttribute("data-import");
1851
+ const nestedElements = el.querySelectorAll("[data-import]");
1852
+ if (nestedElements.length > 0) {
1853
+ const subPromises = Array.from(nestedElements).map((child) => resolveNode(child, new Set(resolvingSet)));
1854
+ await Promise.all(subPromises);
1855
+ }
1856
+ this._scanStoreBinds();
1857
+ this._scanAndFetchAPIBinds();
1858
+ };
1859
+ const promises = Array.from(elements).map((el) => resolveNode(el, /* @__PURE__ */ new Set()));
1860
+ await Promise.all(promises);
1861
+ };
1862
+ clientProto._initSPARouter = function() {
1863
+ if (typeof window === "undefined" || typeof document === "undefined") return;
1864
+ if (this._routerInitialized) return;
1865
+ this._routerInitialized = true;
1866
+ let _spaAbortController = null;
1867
+ const findViewport = () => {
1868
+ const selector = this.options.routerViewport || "main, #viewport, body";
1869
+ const selectors = selector.split(",").map((s) => s.trim());
1870
+ for (const sel of selectors) {
1871
+ const el = document.querySelector(sel);
1872
+ if (el) return el;
1873
+ }
1874
+ return document.body;
1875
+ };
1876
+ const loadPage = async (url, pushState = true) => {
1877
+ try {
1878
+ if (this.options.debug) {
1879
+ console.log(`%c\u{1F6E3}\uFE0F [Dolphin Router]: Navigating to ${url}...`, "color: #3b82f6; font-weight: bold;");
1880
+ }
1881
+ if (_spaAbortController) {
1882
+ _spaAbortController.abort();
1883
+ }
1884
+ _spaAbortController = new AbortController();
1885
+ const signal = _spaAbortController.signal;
1886
+ const viewport = findViewport();
1887
+ if (this.options.routerTransitions && viewport) {
1888
+ viewport.classList.add("dolphin-fade-out");
1889
+ await new Promise((r) => setTimeout(r, 150));
1890
+ }
1891
+ const response = await fetch(url, { signal });
1892
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1893
+ const html = await response.text();
1894
+ _spaAbortController = null;
1895
+ const parser = new DOMParser();
1896
+ const doc = parser.parseFromString(html, "text/html");
1897
+ if (doc.title) {
1898
+ document.title = doc.title;
1899
+ }
1900
+ const newViewport = doc.querySelector(this.options.routerViewport || "main, #viewport, body");
1901
+ const currentViewport = findViewport();
1902
+ if (newViewport && currentViewport) {
1903
+ currentViewport.innerHTML = newViewport.innerHTML;
1904
+ Array.from(newViewport.attributes).forEach((attr) => {
1905
+ currentViewport.setAttribute(attr.name, attr.value);
1906
+ });
1907
+ } else if (currentViewport) {
1908
+ currentViewport.innerHTML = doc.body.innerHTML;
1909
+ }
1910
+ if (pushState) {
1911
+ window.history.pushState({ dolphinSpa: true, url }, "", url);
1912
+ }
1913
+ if (this.options.routerTransitions && currentViewport) {
1914
+ currentViewport.classList.remove("dolphin-fade-out");
1915
+ currentViewport.classList.add("dolphin-fade-in");
1916
+ setTimeout(() => currentViewport.classList.remove("dolphin-fade-in"), 300);
1917
+ }
1918
+ await this._resolveImports(currentViewport);
1919
+ this._scanStoreBinds();
1920
+ this._scanAndFetchAPIBinds();
1921
+ } catch (err) {
1922
+ if (err && err.name === "AbortError") return;
1923
+ console.error("[Dolphin Router Error]: Failed to route page:", err);
1924
+ window.location.href = url;
1925
+ }
1926
+ };
1927
+ this.addDomListener(document, "click", (e) => {
1928
+ const anchor = e.target.closest("a");
1929
+ if (!anchor) return;
1930
+ if (!anchor.hasAttribute("data-spa") && anchor.getAttribute("data-spa") !== "true") return;
1931
+ const href = anchor.getAttribute("href");
1932
+ if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) return;
1933
+ const url = new URL(href, window.location.href);
1934
+ if (url.origin !== window.location.origin) return;
1935
+ e.preventDefault();
1936
+ loadPage(href);
1937
+ });
1938
+ this.addDomListener(window, "popstate", (e) => {
1939
+ if (e.state && e.state.dolphinSpa) {
1940
+ loadPage(e.state.url, false);
1941
+ } else if (e.state === null) {
1942
+ loadPage(window.location.pathname, false);
1943
+ }
1944
+ });
1945
+ if (this.options.routerTransitions) {
1946
+ const style = document.createElement("style");
1947
+ style.innerHTML = `
1948
+ .dolphin-fade-out {
1949
+ opacity: 0;
1950
+ transition: opacity 0.15s ease-in-out;
1951
+ }
1952
+ .dolphin-fade-in {
1953
+ opacity: 0;
1954
+ }
1955
+ main, #viewport, body {
1956
+ transition: opacity 0.15s ease-in-out;
1957
+ }
1958
+ `;
1959
+ document.head.appendChild(style);
1960
+ }
1961
+ };
1475
1962
  }
1476
1963
 
1477
1964
  // src/offline.ts
@@ -1549,6 +2036,10 @@ var DolphinOffline = class {
1549
2036
  const store = transaction.objectStore("cache");
1550
2037
  store.put({ data, timestamp: Date.now() }, key);
1551
2038
  transaction.oncomplete = () => resolve();
2039
+ transaction.onerror = () => {
2040
+ console.warn("[Dolphin Offline] setCache write failed for key:", key);
2041
+ resolve();
2042
+ };
1552
2043
  } catch {
1553
2044
  resolve();
1554
2045
  }
@@ -1571,6 +2062,11 @@ var DolphinOffline = class {
1571
2062
  const store = transaction.objectStore("mutations");
1572
2063
  store.add(mutation);
1573
2064
  transaction.oncomplete = () => resolve();
2065
+ transaction.onerror = () => {
2066
+ console.warn("[Dolphin Offline] queueMutation write failed:", method, path);
2067
+ this.memoryMutations.push(mutation);
2068
+ resolve();
2069
+ };
1574
2070
  } catch {
1575
2071
  resolve();
1576
2072
  }
@@ -1603,6 +2099,10 @@ var DolphinOffline = class {
1603
2099
  const store = transaction.objectStore("mutations");
1604
2100
  store.delete(id);
1605
2101
  transaction.oncomplete = () => resolve();
2102
+ transaction.onerror = () => {
2103
+ console.warn("[Dolphin Offline] removeMutation failed for id:", id);
2104
+ resolve();
2105
+ };
1606
2106
  } catch {
1607
2107
  resolve();
1608
2108
  }
@@ -1948,6 +2448,9 @@ function attachDragDrop(clientProto) {
1948
2448
  function attachCollab(clientProto) {
1949
2449
  clientProto._initCollab = function() {
1950
2450
  if (typeof document === "undefined") return;
2451
+ const remoteCursors = /* @__PURE__ */ new Map();
2452
+ const cursorStaleTimers = /* @__PURE__ */ new Map();
2453
+ const CURSOR_STALE_MS = 5e3;
1951
2454
  this.addDomListener(document, "mousemove", (e) => {
1952
2455
  const shareContainers = document.querySelectorAll("[data-rt-cursor-share]");
1953
2456
  shareContainers.forEach((container) => {
@@ -1984,6 +2487,7 @@ function attachCollab(clientProto) {
1984
2487
  if (e.target._typingTimer) clearTimeout(e.target._typingTimer);
1985
2488
  e.target._typingTimer = setTimeout(() => {
1986
2489
  e.target._isTyping = false;
2490
+ e.target._typingTimer = null;
1987
2491
  publishTyping(false);
1988
2492
  }, 2e3);
1989
2493
  });
@@ -2007,21 +2511,32 @@ function attachCollab(clientProto) {
2007
2511
  if (remoteDeviceId === this.deviceId) return;
2008
2512
  const container = document.querySelector(`[data-rt-cursor-share="${room}"]`);
2009
2513
  if (!container) return;
2010
- let cursorEl = container.querySelector(`.rt-cursor-${remoteDeviceId}`);
2011
- if (!cursorEl) {
2514
+ const cursorKey = `${room}::${remoteDeviceId}`;
2515
+ let cursorEl = remoteCursors.get(cursorKey);
2516
+ if (!cursorEl || !document.contains(cursorEl)) {
2012
2517
  cursorEl = document.createElement("div");
2013
2518
  cursorEl.className = `rt-cursor rt-cursor-${remoteDeviceId}`;
2014
2519
  cursorEl.style.position = "absolute";
2015
2520
  cursorEl.style.width = "10px";
2016
2521
  cursorEl.style.height = "10px";
2017
2522
  cursorEl.style.borderRadius = "50%";
2018
- cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
2523
+ cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
2019
2524
  cursorEl.style.pointerEvents = "none";
2020
2525
  container.appendChild(cursorEl);
2526
+ remoteCursors.set(cursorKey, cursorEl);
2021
2527
  }
2022
2528
  const box = container.getBoundingClientRect();
2023
2529
  cursorEl.style.left = payload.x * box.width + "px";
2024
2530
  cursorEl.style.top = payload.y * box.height + "px";
2531
+ if (cursorStaleTimers.has(cursorKey)) {
2532
+ clearTimeout(cursorStaleTimers.get(cursorKey));
2533
+ }
2534
+ cursorStaleTimers.set(cursorKey, setTimeout(() => {
2535
+ const el = remoteCursors.get(cursorKey);
2536
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2537
+ remoteCursors.delete(cursorKey);
2538
+ cursorStaleTimers.delete(cursorKey);
2539
+ }, CURSOR_STALE_MS));
2025
2540
  });
2026
2541
  this.subscribe("collab/+/crdt", (payload, topic) => {
2027
2542
  if (payload.deviceId === this.deviceId) return;
@@ -2039,6 +2554,14 @@ function attachCollab(clientProto) {
2039
2554
  }
2040
2555
  });
2041
2556
  });
2557
+ this._collabCleanup = () => {
2558
+ cursorStaleTimers.forEach((t) => clearTimeout(t));
2559
+ cursorStaleTimers.clear();
2560
+ remoteCursors.forEach((el) => {
2561
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2562
+ });
2563
+ remoteCursors.clear();
2564
+ };
2042
2565
  };
2043
2566
  }
2044
2567