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.cjs CHANGED
@@ -47,7 +47,21 @@ var APIHandler = class {
47
47
  target.requestDirect = (method, path, body, options) => {
48
48
  return this.requestDirect(method, path, body, options);
49
49
  };
50
- const methods = ["get", "post", "put", "patch", "del", "request", "requestDirect"];
50
+ target._findCSRFToken = () => this._findCSRFToken();
51
+ target._resolveBaseUrl = (path) => this._resolveBaseUrl(path);
52
+ target._normalizeValidationErrors = (errData) => this._normalizeValidationErrors(errData);
53
+ const methods = [
54
+ "get",
55
+ "post",
56
+ "put",
57
+ "patch",
58
+ "del",
59
+ "request",
60
+ "requestDirect",
61
+ "_findCSRFToken",
62
+ "_resolveBaseUrl",
63
+ "_normalizeValidationErrors"
64
+ ];
51
65
  return new Proxy(target, {
52
66
  get: (t, prop) => {
53
67
  if (typeof prop === "string" && !methods.includes(prop)) {
@@ -57,6 +71,108 @@ var APIHandler = class {
57
71
  }
58
72
  });
59
73
  }
74
+ /**
75
+ * Attempts to find a CSRF token in the document (meta tags, forms, or cookies).
76
+ * Works for Laravel, CakePHP, WordPress, Express, NestJS, etc.
77
+ * @private
78
+ */
79
+ _findCSRFToken() {
80
+ if (typeof document === "undefined") return null;
81
+ const metaNames = ["csrf-token", "_csrf", "xsrf-token", "csrf_token"];
82
+ for (const name of metaNames) {
83
+ const metaEl = document.querySelector(`meta[name="${name}"], meta[content][name$="${name}"]`);
84
+ if (metaEl) {
85
+ const token = metaEl.getAttribute("content");
86
+ if (token) return token;
87
+ }
88
+ }
89
+ const inputNames = ["_csrfToken", "_token", "_csrf", "csrf_token"];
90
+ for (const name of inputNames) {
91
+ const inputEl = document.querySelector(`input[type="hidden"][name="${name}"]`);
92
+ if (inputEl && inputEl.value) return inputEl.value;
93
+ }
94
+ const cookies = ["csrfToken", "XSRF-TOKEN", "_csrf", "csrf_token"];
95
+ for (const name of cookies) {
96
+ const match = document.cookie.match(new RegExp("(^|;\\s*)" + name + "=([^;]*)"));
97
+ if (match) return decodeURIComponent(match[2]);
98
+ }
99
+ const wpNonce = typeof window !== "undefined" && window.wpApiSettings?.nonce;
100
+ if (wpNonce) return wpNonce;
101
+ return null;
102
+ }
103
+ /**
104
+ * Dynamically resolves the Base Path/URL from `<base href="...">` or subfolders.
105
+ * @private
106
+ */
107
+ _resolveBaseUrl(path) {
108
+ if (path.startsWith("http://") || path.startsWith("https://")) {
109
+ return path;
110
+ }
111
+ let baseUrl = this.client.httpUrl;
112
+ if (typeof document !== "undefined") {
113
+ const baseEl = document.querySelector("base[href]");
114
+ if (baseEl) {
115
+ const href = baseEl.getAttribute("href") || "";
116
+ if (href && href !== "/") {
117
+ const cleanHref = href.endsWith("/") ? href.slice(0, -1) : href;
118
+ baseUrl = `${this.client.httpUrl}${cleanHref.startsWith("/") ? cleanHref : "/" + cleanHref}`;
119
+ }
120
+ } else {
121
+ const metaBase = document.querySelector('meta[name="base-path"]');
122
+ if (metaBase) {
123
+ const content = metaBase.getAttribute("content") || "";
124
+ if (content && content !== "/") {
125
+ const cleanContent = content.endsWith("/") ? content.slice(0, -1) : content;
126
+ baseUrl = `${this.client.httpUrl}${cleanContent.startsWith("/") ? cleanContent : "/" + cleanContent}`;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ const cleanPath = path.startsWith("/") ? path : "/" + path;
132
+ return `${baseUrl}${cleanPath}`;
133
+ }
134
+ /**
135
+ * Normalizes backend validation errors from major PHP and Node.js frameworks
136
+ * into a unified { [field]: message } object.
137
+ * @private
138
+ */
139
+ _normalizeValidationErrors(errData) {
140
+ const normalized = {};
141
+ if (!errData || typeof errData !== "object") return normalized;
142
+ const errors = errData.errors || errData.validationErrors || errData;
143
+ if (Array.isArray(errors)) {
144
+ for (const err of errors) {
145
+ if (err && typeof err === "object") {
146
+ const field = err.path || err.param || err.field || err.property;
147
+ const msg = err.msg || err.message || err.error;
148
+ if (field && msg) {
149
+ normalized[field] = Array.isArray(msg) ? msg[0] : msg;
150
+ }
151
+ }
152
+ }
153
+ return normalized;
154
+ }
155
+ if (typeof errors === "object" && errors !== null) {
156
+ for (const key in errors) {
157
+ const val = errors[key];
158
+ if (!val) continue;
159
+ if (Array.isArray(val)) {
160
+ if (val.length > 0) {
161
+ normalized[key] = String(val[0]);
162
+ }
163
+ } else if (typeof val === "object") {
164
+ const innerKeys = Object.keys(val);
165
+ if (innerKeys.length > 0) {
166
+ const firstInnerKey = innerKeys[0];
167
+ normalized[key] = String(val[firstInnerKey]);
168
+ }
169
+ } else {
170
+ normalized[key] = String(val);
171
+ }
172
+ }
173
+ }
174
+ return normalized;
175
+ }
60
176
  /**
61
177
  * Intercept request for offline-first caching and queuing.
62
178
  */
@@ -107,30 +223,68 @@ var APIHandler = class {
107
223
  */
108
224
  async requestDirect(method, path, body = null, options = {}) {
109
225
  const _isRetry = options._isRetry === true;
110
- const url = path.startsWith("http://") || path.startsWith("https://") ? path : `${this.client.httpUrl}${path.startsWith("/") ? path : "/" + path}`;
226
+ let finalMethod = method.toUpperCase();
227
+ let finalBody = body;
228
+ const headers = {
229
+ "Content-Type": "application/json",
230
+ ...options.headers || {}
231
+ };
232
+ if (["PUT", "PATCH", "DELETE"].includes(finalMethod)) {
233
+ if (this.client.options.methodSpoofing || options.methodSpoofing) {
234
+ headers["X-HTTP-Method-Override"] = finalMethod;
235
+ if (finalBody instanceof FormData) {
236
+ finalBody.append("_method", finalMethod);
237
+ } else if (finalBody && typeof finalBody === "object") {
238
+ finalBody = {
239
+ ...finalBody,
240
+ _method: finalMethod
241
+ };
242
+ } else if (!finalBody) {
243
+ finalBody = { _method: finalMethod };
244
+ }
245
+ finalMethod = "POST";
246
+ }
247
+ }
248
+ const url = this._resolveBaseUrl(path);
111
249
  if (this.client.options.debug) {
112
- console.log(`%c\u{1F680} [Dolphin API Request]:`, "color: #3b82f6; font-weight: bold;", method.toUpperCase(), path, body || "");
250
+ console.log(`%c\u{1F680} [Dolphin API Request]:`, "color: #3b82f6; font-weight: bold;", method.toUpperCase(), path, finalBody || "");
113
251
  }
114
252
  const controller = new AbortController();
115
253
  const timeoutId = setTimeout(
116
254
  () => controller.abort(),
117
255
  this.client.options.timeout
118
256
  );
119
- const headers = {
120
- "Content-Type": "application/json",
121
- ...options.headers || {}
122
- };
123
257
  if (this.client.accessToken) {
124
258
  headers["Authorization"] = `Bearer ${this.client.accessToken}`;
125
259
  }
260
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase())) {
261
+ const csrfToken = this._findCSRFToken();
262
+ if (csrfToken) {
263
+ headers["X-CSRF-Token"] = csrfToken;
264
+ headers["X-XSRF-TOKEN"] = csrfToken;
265
+ headers["X-CSRFToken"] = csrfToken;
266
+ headers["X-WP-Nonce"] = csrfToken;
267
+ if (finalBody && typeof finalBody === "object") {
268
+ if (!finalBody._csrfToken && !finalBody._token && !finalBody._csrf) {
269
+ finalBody = {
270
+ ...finalBody,
271
+ _csrfToken: csrfToken,
272
+ _token: csrfToken,
273
+ _csrf: csrfToken
274
+ };
275
+ }
276
+ }
277
+ }
278
+ }
126
279
  const fetchOptions = { ...options };
127
280
  delete fetchOptions._isRetry;
281
+ delete fetchOptions.methodSpoofing;
128
282
  try {
129
283
  const response = await fetch(url, {
130
- method,
284
+ method: finalMethod,
131
285
  headers,
132
286
  signal: controller.signal,
133
- ...body ? { body: JSON.stringify(body) } : {},
287
+ ...finalBody ? { body: JSON.stringify(finalBody) } : {},
134
288
  ...fetchOptions
135
289
  });
136
290
  clearTimeout(timeoutId);
@@ -162,6 +316,14 @@ var APIHandler = class {
162
316
  if (this.client.options.debug) {
163
317
  console.error(`%c\u274C [Dolphin API Error]:`, "color: #ef4444; font-weight: bold;", method.toUpperCase(), path, err);
164
318
  }
319
+ if (err && typeof err === "object" && err.data) {
320
+ const normErrors = this._normalizeValidationErrors(err.data);
321
+ if (Object.keys(normErrors).length > 0) {
322
+ for (const field in normErrors) {
323
+ this.client.publish(`errors/${field}`, normErrors[field]);
324
+ }
325
+ }
326
+ }
165
327
  if (err.name === "AbortError") {
166
328
  throw { status: 408, data: { error: "Request timed out" } };
167
329
  }
@@ -295,12 +457,15 @@ var DolphinStore = class {
295
457
  data;
296
458
  listeners;
297
459
  subscribed;
460
+ /** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions (was: subscriptions never removed) */
461
+ _unsubscribers;
298
462
  /** @param {DolphinClient} client */
299
463
  constructor(client) {
300
464
  this.client = client;
301
465
  this.data = /* @__PURE__ */ new Map();
302
466
  this.listeners = /* @__PURE__ */ new Set();
303
467
  this.subscribed = /* @__PURE__ */ new Set();
468
+ this._unsubscribers = /* @__PURE__ */ new Map();
304
469
  return new Proxy(this, {
305
470
  get: (target, prop) => {
306
471
  if (prop in target) return target[prop];
@@ -352,9 +517,12 @@ var DolphinStore = class {
352
517
  state.error = null;
353
518
  this._applyTransform(state);
354
519
  if (!this.subscribed.has(name)) {
355
- this.client.subscribe(`db:sync/${name.toLowerCase()}`, (update) => {
520
+ const unsubscribe = () => this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
521
+ const updateHandler = (update) => {
356
522
  this._handleRemoteUpdate(name, update);
357
- });
523
+ };
524
+ this.client.subscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
525
+ this._unsubscribers.set(name, unsubscribe);
358
526
  this.subscribed.add(name);
359
527
  }
360
528
  } catch (e) {
@@ -411,6 +579,22 @@ var DolphinStore = class {
411
579
  _notify() {
412
580
  this.listeners.forEach((l) => l());
413
581
  }
582
+ /**
583
+ * Clean up all WebSocket subscriptions and listeners.
584
+ * Call this when the store is no longer needed to prevent resource leaks.
585
+ */
586
+ destroy() {
587
+ this._unsubscribers.forEach((unsub) => {
588
+ try {
589
+ unsub();
590
+ } catch {
591
+ }
592
+ });
593
+ this._unsubscribers.clear();
594
+ this.subscribed.clear();
595
+ this.listeners.clear();
596
+ this.data.clear();
597
+ }
414
598
  };
415
599
 
416
600
  // src/core.ts
@@ -430,6 +614,8 @@ var DolphinClient = class {
430
614
  fileHandlers;
431
615
  _offlineQueue;
432
616
  reconnectAttempts;
617
+ /** @fix: Store timer ID so disconnect() can cancel pending reconnects (was: memory/logic leak) */
618
+ _reconnectTimer;
433
619
  _attachedListeners;
434
620
  constructor(url = "", deviceId = "", options = {}) {
435
621
  if (!url && typeof window !== "undefined") url = window.location.host;
@@ -439,7 +625,7 @@ var DolphinClient = class {
439
625
  else if (typeof window !== "undefined") protocol = window.location.protocol;
440
626
  this.host = (url || "localhost").replace(/\/$/, "").replace(/^https?:\/\//, "");
441
627
  this.httpUrl = `${protocol}//${this.host}`;
442
- this.deviceId = deviceId || "web_" + Math.random().toString(36).substr(2, 8);
628
+ 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));
443
629
  this.options = {
444
630
  timeout: 15e3,
445
631
  chunkSize: 65536,
@@ -447,6 +633,9 @@ var DolphinClient = class {
447
633
  maxReconnect: 5,
448
634
  autoRefreshToken: true,
449
635
  debug: false,
636
+ methodSpoofing: false,
637
+ routerViewport: "main, #viewport, body",
638
+ routerTransitions: true,
450
639
  ...options
451
640
  };
452
641
  this.socket = null;
@@ -466,6 +655,7 @@ var DolphinClient = class {
466
655
  this.fileHandlers = /* @__PURE__ */ new Set();
467
656
  this._offlineQueue = [];
468
657
  this.reconnectAttempts = 0;
658
+ this._reconnectTimer = null;
469
659
  this._attachedListeners = [];
470
660
  if (typeof window !== "undefined" && typeof this._initDOMBinding === "function") {
471
661
  this._initDOMBinding();
@@ -494,6 +684,9 @@ var DolphinClient = class {
494
684
  // ── WebSocket ─────────────────────────────────────────────────────────────
495
685
  /** Connect to the Dolphin realtime server */
496
686
  async connect() {
687
+ if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
688
+ return Promise.resolve();
689
+ }
497
690
  return new Promise((resolve, reject) => {
498
691
  const protocol = this.httpUrl.startsWith("https") ? "wss:" : "ws:";
499
692
  const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
@@ -518,11 +711,18 @@ var DolphinClient = class {
518
711
  }
519
712
  /** Disconnect cleanly */
520
713
  disconnect() {
714
+ if (this._reconnectTimer !== null) {
715
+ clearTimeout(this._reconnectTimer);
716
+ this._reconnectTimer = null;
717
+ }
521
718
  if (this.socket) {
522
719
  this.socket.onclose = null;
523
720
  this.socket.close();
524
721
  this.socket = null;
525
722
  }
723
+ if (typeof this._collabCleanup === "function") {
724
+ this._collabCleanup();
725
+ }
526
726
  this.cleanupDomListeners();
527
727
  }
528
728
  /** @private */
@@ -610,8 +810,11 @@ var DolphinClient = class {
610
810
  this.reconnectAttempts++;
611
811
  const delay = Math.pow(2, this.reconnectAttempts) * 1e3;
612
812
  console.log(`[Dolphin] Reconnecting in ${delay / 1e3}s (attempt ${this.reconnectAttempts})...`);
613
- setTimeout(() => this.connect().catch(() => {
614
- }), delay);
813
+ this._reconnectTimer = setTimeout(() => {
814
+ this._reconnectTimer = null;
815
+ this.connect().catch(() => {
816
+ });
817
+ }, delay);
615
818
  } else {
616
819
  console.error("[Dolphin] Max reconnect attempts reached.");
617
820
  }
@@ -789,6 +992,7 @@ var DolphinClient = class {
789
992
 
790
993
  // src/dom.ts
791
994
  function attachDOMBinding(clientProto) {
995
+ const componentPromiseCache = /* @__PURE__ */ new Map();
792
996
  function escapeRegExp(str) {
793
997
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
794
998
  }
@@ -1035,7 +1239,9 @@ function attachDOMBinding(clientProto) {
1035
1239
  const scheduleFn = typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : (cb) => setTimeout(cb, 0);
1036
1240
  scheduleFn(() => {
1037
1241
  pendingUpdates.forEach((html, el) => {
1038
- patchDOM(el, html);
1242
+ if (el.isConnected !== false) {
1243
+ patchDOM(el, html);
1244
+ }
1039
1245
  });
1040
1246
  pendingUpdates.clear();
1041
1247
  rafScheduled = false;
@@ -1174,7 +1380,7 @@ function attachDOMBinding(clientProto) {
1174
1380
  if (this._domInitialized) return;
1175
1381
  this._domInitialized = true;
1176
1382
  const PUSH_EVENTS = ["input", "change", "keyup", "paste", "blur"];
1177
- const debounceTimers = /* @__PURE__ */ new Map();
1383
+ const debounceTimers = /* @__PURE__ */ new WeakMap();
1178
1384
  PUSH_EVENTS.forEach((evtName) => {
1179
1385
  this.addDomListener(document, evtName, (e) => {
1180
1386
  if (!e.target || !e.target.getAttribute) return;
@@ -1227,6 +1433,14 @@ function attachDOMBinding(clientProto) {
1227
1433
  const rtTopic = e.target.getAttribute("data-rt-submit");
1228
1434
  const apiTarget = e.target.getAttribute("data-api-submit");
1229
1435
  if (rtTopic || apiTarget) {
1436
+ const formInputs = e.target.querySelectorAll("[name]");
1437
+ formInputs.forEach((inputEl) => {
1438
+ const name = inputEl.name;
1439
+ if (name) {
1440
+ this.publish(`errors/${name}`, "");
1441
+ inputEl.classList.remove("invalid");
1442
+ }
1443
+ });
1230
1444
  const validatedInputs = e.target.querySelectorAll("[data-rt-validate]");
1231
1445
  let formIsValid = true;
1232
1446
  if (validatedInputs.length > 0 && typeof this.validateField === "function") {
@@ -1240,9 +1454,6 @@ function attachDOMBinding(clientProto) {
1240
1454
  formIsValid = false;
1241
1455
  inputEl.classList.add("invalid");
1242
1456
  this.publish(`errors/${name}`, errorMsg);
1243
- } else {
1244
- inputEl.classList.remove("invalid");
1245
- this.publish(`errors/${name}`, "");
1246
1457
  }
1247
1458
  }
1248
1459
  });
@@ -1270,8 +1481,11 @@ function attachDOMBinding(clientProto) {
1270
1481
  resolvedTarget = resolvedTarget.replace(new RegExp(`\\{\\{${escapedK}\\}\\}`, "g"), parentCtx[k] !== void 0 && parentCtx[k] !== null ? parentCtx[k] : "");
1271
1482
  }
1272
1483
  const parts = resolvedTarget.trim().split(" ");
1273
- const method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
1484
+ let method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
1274
1485
  const path = parts.length > 1 ? parts[1] : parts[0];
1486
+ if (data._method) {
1487
+ method = String(data._method).toUpperCase();
1488
+ }
1275
1489
  try {
1276
1490
  const result = await this.api.request(method, path, data);
1277
1491
  const resultBind = e.target.getAttribute("data-api-result");
@@ -1358,6 +1572,8 @@ function attachDOMBinding(clientProto) {
1358
1572
  });
1359
1573
  this._scanAndFetchAPIBinds();
1360
1574
  this._scanStoreBinds();
1575
+ this._resolveImports();
1576
+ this._initSPARouter();
1361
1577
  };
1362
1578
  clientProto._scanAndFetchAPIBinds = async function() {
1363
1579
  if (typeof document === "undefined") return;
@@ -1365,6 +1581,10 @@ function attachDOMBinding(clientProto) {
1365
1581
  for (const el of Array.from(elements)) {
1366
1582
  const path = el.getAttribute("data-api-get");
1367
1583
  if (!path) continue;
1584
+ if (typeof el.hasAttribute === "function" && el.hasAttribute("data-api-initialized")) continue;
1585
+ if (typeof el.setAttribute === "function") {
1586
+ el.setAttribute("data-api-initialized", "true");
1587
+ }
1368
1588
  try {
1369
1589
  const result = await this.api.get(path);
1370
1590
  const apiStore = el.getAttribute("data-api-store");
@@ -1380,20 +1600,22 @@ function attachDOMBinding(clientProto) {
1380
1600
  } else if (!apiStore) {
1381
1601
  const template = resolveTemplate(el);
1382
1602
  if (template && typeof result === "object" && result !== null) {
1383
- if (Array.isArray(result)) {
1603
+ const processedResult = this._applyDeclarativeDirectives(el, result);
1604
+ if (Array.isArray(processedResult)) {
1384
1605
  let combinedHTML = "";
1385
- for (const item of result) {
1606
+ for (const item of processedResult) {
1386
1607
  combinedHTML += renderTemplate(template, item);
1387
1608
  }
1388
1609
  scheduleDOMUpdate(el, combinedHTML);
1389
1610
  } else {
1390
- scheduleDOMUpdate(el, renderTemplate(template, result));
1611
+ scheduleDOMUpdate(el, renderTemplate(template, processedResult));
1391
1612
  }
1392
1613
  } else {
1393
1614
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1394
1615
  el.value = typeof result === "object" ? result.value !== void 0 ? result.value : "" : result;
1395
1616
  } else {
1396
- el.innerHTML = typeof result === "object" ? result.html || result.text || JSON.stringify(result) : result;
1617
+ const rawHTML = typeof result === "object" ? result.html || result.text || JSON.stringify(result) : String(result);
1618
+ el.innerHTML = sanitizeHTML(rawHTML);
1397
1619
  }
1398
1620
  }
1399
1621
  }
@@ -1402,21 +1624,139 @@ function attachDOMBinding(clientProto) {
1402
1624
  }
1403
1625
  }
1404
1626
  };
1627
+ clientProto._applyDeclarativeDirectives = function(el, payload) {
1628
+ let processedPayload = payload;
1629
+ if (typeof payload === "object" && payload !== null) {
1630
+ const applyFilterSearchSort = (arr) => {
1631
+ let result = [...arr];
1632
+ const filterAttr = el.getAttribute("data-rt-filter");
1633
+ if (filterAttr) {
1634
+ const parts = filterAttr.split("==");
1635
+ if (parts.length === 2) {
1636
+ const itemProp = parts[0].trim();
1637
+ const storeExpr = parts[1].trim();
1638
+ let filterVal = void 0;
1639
+ const storeParts = storeExpr.split(".");
1640
+ if (storeParts.length === 2) {
1641
+ filterVal = this.getStoreState(storeParts[0], storeParts[1]);
1642
+ } else {
1643
+ filterVal = payload[storeExpr] !== void 0 ? payload[storeExpr] : this.getStoreState("app", storeExpr);
1644
+ }
1645
+ if (filterVal !== void 0 && filterVal !== null && filterVal !== "") {
1646
+ result = result.filter((item) => item[itemProp] === filterVal);
1647
+ }
1648
+ }
1649
+ }
1650
+ const searchAttr = el.getAttribute("data-rt-search");
1651
+ if (searchAttr) {
1652
+ const parts = searchAttr.split("==");
1653
+ if (parts.length === 2) {
1654
+ const itemProp = parts[0].trim();
1655
+ const storeExpr = parts[1].trim();
1656
+ let searchVal = void 0;
1657
+ const storeParts = storeExpr.split(".");
1658
+ if (storeParts.length === 2) {
1659
+ searchVal = this.getStoreState(storeParts[0], storeParts[1]);
1660
+ } else {
1661
+ searchVal = payload[storeExpr] !== void 0 ? payload[storeExpr] : this.getStoreState("app", storeExpr);
1662
+ }
1663
+ if (searchVal !== void 0 && searchVal !== null && searchVal !== "") {
1664
+ const query = String(searchVal).toLowerCase();
1665
+ result = result.filter((item) => {
1666
+ const val = item[itemProp];
1667
+ return val !== void 0 && val !== null && String(val).toLowerCase().includes(query);
1668
+ });
1669
+ }
1670
+ }
1671
+ }
1672
+ const sortAttr = el.getAttribute("data-rt-sort");
1673
+ if (sortAttr) {
1674
+ let sortByVal = void 0;
1675
+ const storeParts = sortAttr.split(".");
1676
+ if (storeParts.length === 2) {
1677
+ sortByVal = this.getStoreState(storeParts[0], storeParts[1]);
1678
+ } else {
1679
+ sortByVal = payload[sortAttr] !== void 0 ? payload[sortAttr] : this.getStoreState("app", sortAttr);
1680
+ }
1681
+ if (sortByVal && sortByVal !== "") {
1682
+ if (sortByVal === "popular") {
1683
+ result.sort((a, b) => {
1684
+ const rateA = a.rating?.rate || a.rate || 0;
1685
+ const rateB = b.rating?.rate || b.rate || 0;
1686
+ return rateB - rateA;
1687
+ });
1688
+ } else {
1689
+ let field = "";
1690
+ let direction = "asc";
1691
+ if (sortByVal.endsWith("-low") || sortByVal.endsWith("-asc")) {
1692
+ field = sortByVal.replace("-low", "").replace("-asc", "");
1693
+ direction = "asc";
1694
+ } else if (sortByVal.endsWith("-high") || sortByVal.endsWith("-desc")) {
1695
+ field = sortByVal.replace("-high", "").replace("-desc", "");
1696
+ direction = "desc";
1697
+ }
1698
+ if (field) {
1699
+ result.sort((a, b) => {
1700
+ const resolveVal = (obj, path) => {
1701
+ return path.split(".").reduce((acc, part) => acc && acc[part], obj);
1702
+ };
1703
+ let valA = resolveVal(a, field);
1704
+ let valB = resolveVal(b, field);
1705
+ if (valA === void 0) valA = a[field];
1706
+ if (valB === void 0) valB = b[field];
1707
+ if (typeof valA === "string" && typeof valB === "string") {
1708
+ return direction === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA);
1709
+ }
1710
+ const numA = Number(valA);
1711
+ const numB = Number(valB);
1712
+ if (!isNaN(numA) && !isNaN(numB)) {
1713
+ return direction === "asc" ? numA - numB : numB - numA;
1714
+ }
1715
+ return 0;
1716
+ });
1717
+ }
1718
+ }
1719
+ }
1720
+ }
1721
+ return result;
1722
+ };
1723
+ if (Array.isArray(payload)) {
1724
+ processedPayload = applyFilterSearchSort(payload);
1725
+ } else {
1726
+ let foundArrayKey = "";
1727
+ for (const key in payload) {
1728
+ if (Array.isArray(payload[key])) {
1729
+ foundArrayKey = key;
1730
+ break;
1731
+ }
1732
+ }
1733
+ if (foundArrayKey) {
1734
+ const processedArray = applyFilterSearchSort(payload[foundArrayKey]);
1735
+ processedPayload = {
1736
+ ...payload,
1737
+ [foundArrayKey]: processedArray
1738
+ };
1739
+ }
1740
+ }
1741
+ }
1742
+ return processedPayload;
1743
+ };
1405
1744
  clientProto._updateDOM = function(topic, payload) {
1406
1745
  if (typeof document === "undefined") return;
1407
1746
  const elements = document.querySelectorAll(`[data-rt-bind="${topic}"]`);
1408
1747
  elements.forEach((el) => {
1409
- if (el.getAttribute("data-rt-type") === "context" && typeof payload === "object" && payload !== null) {
1410
- el._rtContext = payload;
1748
+ const processedPayload = this._applyDeclarativeDirectives(el, payload);
1749
+ if (el.getAttribute("data-rt-type") === "context" && typeof processedPayload === "object" && processedPayload !== null) {
1750
+ el._rtContext = processedPayload;
1411
1751
  const processNode = (node) => {
1412
1752
  if (node.hasAttribute("data-rt-text")) {
1413
1753
  const key = node.getAttribute("data-rt-text");
1414
- if (key && payload[key] !== void 0 && payload[key] !== null) node.textContent = payload[key];
1754
+ if (key && processedPayload[key] !== void 0 && processedPayload[key] !== null) node.textContent = processedPayload[key];
1415
1755
  }
1416
1756
  if (node.hasAttribute("data-rt-html")) {
1417
1757
  const key = node.getAttribute("data-rt-html");
1418
- if (key && payload[key] !== void 0 && payload[key] !== null) {
1419
- node.innerHTML = sanitizeHTML(payload[key]);
1758
+ if (key && processedPayload[key] !== void 0 && processedPayload[key] !== null) {
1759
+ node.innerHTML = sanitizeHTML(processedPayload[key]);
1420
1760
  }
1421
1761
  }
1422
1762
  if (node.hasAttribute("data-rt-attr")) {
@@ -1427,8 +1767,8 @@ function attachDOMBinding(clientProto) {
1427
1767
  if (parts.length === 2) {
1428
1768
  const attrName = parts[0].trim();
1429
1769
  const key = parts[1].trim();
1430
- if (attrName && key && payload[key] !== void 0 && payload[key] !== null) {
1431
- node.setAttribute(attrName, payload[key]);
1770
+ if (attrName && key && processedPayload[key] !== void 0 && processedPayload[key] !== null) {
1771
+ node.setAttribute(attrName, processedPayload[key]);
1432
1772
  }
1433
1773
  }
1434
1774
  });
@@ -1443,7 +1783,7 @@ function attachDOMBinding(clientProto) {
1443
1783
  const className = parts[0].trim();
1444
1784
  const key = parts[1].trim();
1445
1785
  const classNames = className.split(/\s+/).filter(Boolean);
1446
- if (payload[key]) {
1786
+ if (processedPayload[key]) {
1447
1787
  classNames.forEach((c) => node.classList.add(c));
1448
1788
  } else {
1449
1789
  classNames.forEach((c) => node.classList.remove(c));
@@ -1455,7 +1795,7 @@ function attachDOMBinding(clientProto) {
1455
1795
  if (node.hasAttribute("data-rt-if")) {
1456
1796
  const key = node.getAttribute("data-rt-if");
1457
1797
  if (key) {
1458
- if (payload[key]) {
1798
+ if (processedPayload[key]) {
1459
1799
  node.style.display = "";
1460
1800
  } else {
1461
1801
  node.style.display = "none";
@@ -1465,7 +1805,7 @@ function attachDOMBinding(clientProto) {
1465
1805
  if (node.hasAttribute("data-rt-hide")) {
1466
1806
  const key = node.getAttribute("data-rt-hide");
1467
1807
  if (key) {
1468
- if (payload[key]) {
1808
+ if (processedPayload[key]) {
1469
1809
  node.style.display = "none";
1470
1810
  } else {
1471
1811
  node.style.display = "";
@@ -1478,25 +1818,172 @@ function attachDOMBinding(clientProto) {
1478
1818
  return;
1479
1819
  }
1480
1820
  const template = resolveTemplate(el);
1481
- if (template && typeof payload === "object" && payload !== null) {
1482
- if (Array.isArray(payload)) {
1821
+ if (template && typeof processedPayload === "object" && processedPayload !== null) {
1822
+ if (Array.isArray(processedPayload)) {
1483
1823
  let combinedHTML = "";
1484
- for (const item of payload) {
1824
+ for (const item of processedPayload) {
1485
1825
  combinedHTML += renderTemplate(template, item);
1486
1826
  }
1487
1827
  scheduleDOMUpdate(el, combinedHTML);
1488
1828
  } else {
1489
- scheduleDOMUpdate(el, renderTemplate(template, payload));
1829
+ scheduleDOMUpdate(el, renderTemplate(template, processedPayload));
1490
1830
  }
1491
1831
  return;
1492
1832
  }
1493
1833
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1494
- el.value = typeof payload === "object" ? payload.value !== void 0 ? payload.value : "" : payload;
1834
+ el.value = typeof processedPayload === "object" ? processedPayload.value !== void 0 ? processedPayload.value : "" : processedPayload;
1835
+ } else if (template) {
1836
+ el.innerHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1495
1837
  } else {
1496
- el.innerHTML = typeof payload === "object" ? payload.html || payload.text || JSON.stringify(payload) : payload;
1838
+ const rawHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1839
+ el.innerHTML = sanitizeHTML(rawHTML);
1497
1840
  }
1498
1841
  });
1499
1842
  };
1843
+ clientProto._resolveImports = async function(container) {
1844
+ if (typeof document === "undefined") return;
1845
+ const root = container || document.body || document;
1846
+ if (!root || typeof root.querySelectorAll !== "function") return;
1847
+ const elements = root.querySelectorAll("[data-import]");
1848
+ if (elements.length === 0) return;
1849
+ const resolveNode = async (el, resolvingSet) => {
1850
+ const src = el.getAttribute("data-import");
1851
+ if (!src) return;
1852
+ if (resolvingSet.has(src)) {
1853
+ console.warn(`[Dolphin Component Warning]: Circular import detected for "${src}". Skipping resolving.`);
1854
+ el.innerHTML = `<span style="color:red;font-weight:bold;">Circular import: ${src}</span>`;
1855
+ return;
1856
+ }
1857
+ resolvingSet.add(src);
1858
+ let promise = componentPromiseCache.get(src);
1859
+ if (!promise) {
1860
+ promise = fetch(src).then((res) => {
1861
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1862
+ return res.text();
1863
+ });
1864
+ promise.catch(() => componentPromiseCache.delete(src));
1865
+ componentPromiseCache.set(src, promise);
1866
+ }
1867
+ let content = "";
1868
+ try {
1869
+ content = await promise;
1870
+ } catch (err) {
1871
+ console.error(`[Dolphin Component Error]: Failed to fetch component "${src}":`, err);
1872
+ content = `<span style="color:red;font-weight:bold;">Failed to import ${src}</span>`;
1873
+ }
1874
+ el.innerHTML = sanitizeHTML(content);
1875
+ el.removeAttribute("data-import");
1876
+ const nestedElements = el.querySelectorAll("[data-import]");
1877
+ if (nestedElements.length > 0) {
1878
+ const subPromises = Array.from(nestedElements).map((child) => resolveNode(child, new Set(resolvingSet)));
1879
+ await Promise.all(subPromises);
1880
+ }
1881
+ this._scanStoreBinds();
1882
+ this._scanAndFetchAPIBinds();
1883
+ };
1884
+ const promises = Array.from(elements).map((el) => resolveNode(el, /* @__PURE__ */ new Set()));
1885
+ await Promise.all(promises);
1886
+ };
1887
+ clientProto._initSPARouter = function() {
1888
+ if (typeof window === "undefined" || typeof document === "undefined") return;
1889
+ if (this._routerInitialized) return;
1890
+ this._routerInitialized = true;
1891
+ let _spaAbortController = null;
1892
+ const findViewport = () => {
1893
+ const selector = this.options.routerViewport || "main, #viewport, body";
1894
+ const selectors = selector.split(",").map((s) => s.trim());
1895
+ for (const sel of selectors) {
1896
+ const el = document.querySelector(sel);
1897
+ if (el) return el;
1898
+ }
1899
+ return document.body;
1900
+ };
1901
+ const loadPage = async (url, pushState = true) => {
1902
+ try {
1903
+ if (this.options.debug) {
1904
+ console.log(`%c\u{1F6E3}\uFE0F [Dolphin Router]: Navigating to ${url}...`, "color: #3b82f6; font-weight: bold;");
1905
+ }
1906
+ if (_spaAbortController) {
1907
+ _spaAbortController.abort();
1908
+ }
1909
+ _spaAbortController = new AbortController();
1910
+ const signal = _spaAbortController.signal;
1911
+ const viewport = findViewport();
1912
+ if (this.options.routerTransitions && viewport) {
1913
+ viewport.classList.add("dolphin-fade-out");
1914
+ await new Promise((r) => setTimeout(r, 150));
1915
+ }
1916
+ const response = await fetch(url, { signal });
1917
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1918
+ const html = await response.text();
1919
+ _spaAbortController = null;
1920
+ const parser = new DOMParser();
1921
+ const doc = parser.parseFromString(html, "text/html");
1922
+ if (doc.title) {
1923
+ document.title = doc.title;
1924
+ }
1925
+ const newViewport = doc.querySelector(this.options.routerViewport || "main, #viewport, body");
1926
+ const currentViewport = findViewport();
1927
+ if (newViewport && currentViewport) {
1928
+ currentViewport.innerHTML = newViewport.innerHTML;
1929
+ Array.from(newViewport.attributes).forEach((attr) => {
1930
+ currentViewport.setAttribute(attr.name, attr.value);
1931
+ });
1932
+ } else if (currentViewport) {
1933
+ currentViewport.innerHTML = doc.body.innerHTML;
1934
+ }
1935
+ if (pushState) {
1936
+ window.history.pushState({ dolphinSpa: true, url }, "", url);
1937
+ }
1938
+ if (this.options.routerTransitions && currentViewport) {
1939
+ currentViewport.classList.remove("dolphin-fade-out");
1940
+ currentViewport.classList.add("dolphin-fade-in");
1941
+ setTimeout(() => currentViewport.classList.remove("dolphin-fade-in"), 300);
1942
+ }
1943
+ await this._resolveImports(currentViewport);
1944
+ this._scanStoreBinds();
1945
+ this._scanAndFetchAPIBinds();
1946
+ } catch (err) {
1947
+ if (err && err.name === "AbortError") return;
1948
+ console.error("[Dolphin Router Error]: Failed to route page:", err);
1949
+ window.location.href = url;
1950
+ }
1951
+ };
1952
+ this.addDomListener(document, "click", (e) => {
1953
+ const anchor = e.target.closest("a");
1954
+ if (!anchor) return;
1955
+ if (!anchor.hasAttribute("data-spa") && anchor.getAttribute("data-spa") !== "true") return;
1956
+ const href = anchor.getAttribute("href");
1957
+ if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) return;
1958
+ const url = new URL(href, window.location.href);
1959
+ if (url.origin !== window.location.origin) return;
1960
+ e.preventDefault();
1961
+ loadPage(href);
1962
+ });
1963
+ this.addDomListener(window, "popstate", (e) => {
1964
+ if (e.state && e.state.dolphinSpa) {
1965
+ loadPage(e.state.url, false);
1966
+ } else if (e.state === null) {
1967
+ loadPage(window.location.pathname, false);
1968
+ }
1969
+ });
1970
+ if (this.options.routerTransitions) {
1971
+ const style = document.createElement("style");
1972
+ style.innerHTML = `
1973
+ .dolphin-fade-out {
1974
+ opacity: 0;
1975
+ transition: opacity 0.15s ease-in-out;
1976
+ }
1977
+ .dolphin-fade-in {
1978
+ opacity: 0;
1979
+ }
1980
+ main, #viewport, body {
1981
+ transition: opacity 0.15s ease-in-out;
1982
+ }
1983
+ `;
1984
+ document.head.appendChild(style);
1985
+ }
1986
+ };
1500
1987
  }
1501
1988
 
1502
1989
  // src/offline.ts
@@ -1574,6 +2061,10 @@ var DolphinOffline = class {
1574
2061
  const store = transaction.objectStore("cache");
1575
2062
  store.put({ data, timestamp: Date.now() }, key);
1576
2063
  transaction.oncomplete = () => resolve();
2064
+ transaction.onerror = () => {
2065
+ console.warn("[Dolphin Offline] setCache write failed for key:", key);
2066
+ resolve();
2067
+ };
1577
2068
  } catch {
1578
2069
  resolve();
1579
2070
  }
@@ -1596,6 +2087,11 @@ var DolphinOffline = class {
1596
2087
  const store = transaction.objectStore("mutations");
1597
2088
  store.add(mutation);
1598
2089
  transaction.oncomplete = () => resolve();
2090
+ transaction.onerror = () => {
2091
+ console.warn("[Dolphin Offline] queueMutation write failed:", method, path);
2092
+ this.memoryMutations.push(mutation);
2093
+ resolve();
2094
+ };
1599
2095
  } catch {
1600
2096
  resolve();
1601
2097
  }
@@ -1628,6 +2124,10 @@ var DolphinOffline = class {
1628
2124
  const store = transaction.objectStore("mutations");
1629
2125
  store.delete(id);
1630
2126
  transaction.oncomplete = () => resolve();
2127
+ transaction.onerror = () => {
2128
+ console.warn("[Dolphin Offline] removeMutation failed for id:", id);
2129
+ resolve();
2130
+ };
1631
2131
  } catch {
1632
2132
  resolve();
1633
2133
  }
@@ -1973,6 +2473,9 @@ function attachDragDrop(clientProto) {
1973
2473
  function attachCollab(clientProto) {
1974
2474
  clientProto._initCollab = function() {
1975
2475
  if (typeof document === "undefined") return;
2476
+ const remoteCursors = /* @__PURE__ */ new Map();
2477
+ const cursorStaleTimers = /* @__PURE__ */ new Map();
2478
+ const CURSOR_STALE_MS = 5e3;
1976
2479
  this.addDomListener(document, "mousemove", (e) => {
1977
2480
  const shareContainers = document.querySelectorAll("[data-rt-cursor-share]");
1978
2481
  shareContainers.forEach((container) => {
@@ -2009,6 +2512,7 @@ function attachCollab(clientProto) {
2009
2512
  if (e.target._typingTimer) clearTimeout(e.target._typingTimer);
2010
2513
  e.target._typingTimer = setTimeout(() => {
2011
2514
  e.target._isTyping = false;
2515
+ e.target._typingTimer = null;
2012
2516
  publishTyping(false);
2013
2517
  }, 2e3);
2014
2518
  });
@@ -2032,21 +2536,32 @@ function attachCollab(clientProto) {
2032
2536
  if (remoteDeviceId === this.deviceId) return;
2033
2537
  const container = document.querySelector(`[data-rt-cursor-share="${room}"]`);
2034
2538
  if (!container) return;
2035
- let cursorEl = container.querySelector(`.rt-cursor-${remoteDeviceId}`);
2036
- if (!cursorEl) {
2539
+ const cursorKey = `${room}::${remoteDeviceId}`;
2540
+ let cursorEl = remoteCursors.get(cursorKey);
2541
+ if (!cursorEl || !document.contains(cursorEl)) {
2037
2542
  cursorEl = document.createElement("div");
2038
2543
  cursorEl.className = `rt-cursor rt-cursor-${remoteDeviceId}`;
2039
2544
  cursorEl.style.position = "absolute";
2040
2545
  cursorEl.style.width = "10px";
2041
2546
  cursorEl.style.height = "10px";
2042
2547
  cursorEl.style.borderRadius = "50%";
2043
- cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
2548
+ cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
2044
2549
  cursorEl.style.pointerEvents = "none";
2045
2550
  container.appendChild(cursorEl);
2551
+ remoteCursors.set(cursorKey, cursorEl);
2046
2552
  }
2047
2553
  const box = container.getBoundingClientRect();
2048
2554
  cursorEl.style.left = payload.x * box.width + "px";
2049
2555
  cursorEl.style.top = payload.y * box.height + "px";
2556
+ if (cursorStaleTimers.has(cursorKey)) {
2557
+ clearTimeout(cursorStaleTimers.get(cursorKey));
2558
+ }
2559
+ cursorStaleTimers.set(cursorKey, setTimeout(() => {
2560
+ const el = remoteCursors.get(cursorKey);
2561
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2562
+ remoteCursors.delete(cursorKey);
2563
+ cursorStaleTimers.delete(cursorKey);
2564
+ }, CURSOR_STALE_MS));
2050
2565
  });
2051
2566
  this.subscribe("collab/+/crdt", (payload, topic) => {
2052
2567
  if (payload.deviceId === this.deviceId) return;
@@ -2064,6 +2579,14 @@ function attachCollab(clientProto) {
2064
2579
  }
2065
2580
  });
2066
2581
  });
2582
+ this._collabCleanup = () => {
2583
+ cursorStaleTimers.forEach((t) => clearTimeout(t));
2584
+ cursorStaleTimers.clear();
2585
+ remoteCursors.forEach((el) => {
2586
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2587
+ });
2588
+ remoteCursors.clear();
2589
+ };
2067
2590
  };
2068
2591
  }
2069
2592