dolphin-client 1.0.5 → 1.0.9

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");
@@ -1369,7 +1589,8 @@ function attachDOMBinding(clientProto) {
1369
1589
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1370
1590
  el.value = typeof result === "object" ? result.value !== void 0 ? result.value : "" : result;
1371
1591
  } else {
1372
- 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);
1373
1594
  }
1374
1595
  }
1375
1596
  }
@@ -1586,11 +1807,158 @@ function attachDOMBinding(clientProto) {
1586
1807
  }
1587
1808
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1588
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);
1589
1812
  } else {
1590
- el.innerHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : processedPayload;
1813
+ const rawHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1814
+ el.innerHTML = sanitizeHTML(rawHTML);
1591
1815
  }
1592
1816
  });
1593
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
+ };
1594
1962
  }
1595
1963
 
1596
1964
  // src/offline.ts
@@ -1668,6 +2036,10 @@ var DolphinOffline = class {
1668
2036
  const store = transaction.objectStore("cache");
1669
2037
  store.put({ data, timestamp: Date.now() }, key);
1670
2038
  transaction.oncomplete = () => resolve();
2039
+ transaction.onerror = () => {
2040
+ console.warn("[Dolphin Offline] setCache write failed for key:", key);
2041
+ resolve();
2042
+ };
1671
2043
  } catch {
1672
2044
  resolve();
1673
2045
  }
@@ -1690,6 +2062,11 @@ var DolphinOffline = class {
1690
2062
  const store = transaction.objectStore("mutations");
1691
2063
  store.add(mutation);
1692
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
+ };
1693
2070
  } catch {
1694
2071
  resolve();
1695
2072
  }
@@ -1722,6 +2099,10 @@ var DolphinOffline = class {
1722
2099
  const store = transaction.objectStore("mutations");
1723
2100
  store.delete(id);
1724
2101
  transaction.oncomplete = () => resolve();
2102
+ transaction.onerror = () => {
2103
+ console.warn("[Dolphin Offline] removeMutation failed for id:", id);
2104
+ resolve();
2105
+ };
1725
2106
  } catch {
1726
2107
  resolve();
1727
2108
  }
@@ -2067,6 +2448,9 @@ function attachDragDrop(clientProto) {
2067
2448
  function attachCollab(clientProto) {
2068
2449
  clientProto._initCollab = function() {
2069
2450
  if (typeof document === "undefined") return;
2451
+ const remoteCursors = /* @__PURE__ */ new Map();
2452
+ const cursorStaleTimers = /* @__PURE__ */ new Map();
2453
+ const CURSOR_STALE_MS = 5e3;
2070
2454
  this.addDomListener(document, "mousemove", (e) => {
2071
2455
  const shareContainers = document.querySelectorAll("[data-rt-cursor-share]");
2072
2456
  shareContainers.forEach((container) => {
@@ -2103,6 +2487,7 @@ function attachCollab(clientProto) {
2103
2487
  if (e.target._typingTimer) clearTimeout(e.target._typingTimer);
2104
2488
  e.target._typingTimer = setTimeout(() => {
2105
2489
  e.target._isTyping = false;
2490
+ e.target._typingTimer = null;
2106
2491
  publishTyping(false);
2107
2492
  }, 2e3);
2108
2493
  });
@@ -2126,21 +2511,32 @@ function attachCollab(clientProto) {
2126
2511
  if (remoteDeviceId === this.deviceId) return;
2127
2512
  const container = document.querySelector(`[data-rt-cursor-share="${room}"]`);
2128
2513
  if (!container) return;
2129
- let cursorEl = container.querySelector(`.rt-cursor-${remoteDeviceId}`);
2130
- if (!cursorEl) {
2514
+ const cursorKey = `${room}::${remoteDeviceId}`;
2515
+ let cursorEl = remoteCursors.get(cursorKey);
2516
+ if (!cursorEl || !document.contains(cursorEl)) {
2131
2517
  cursorEl = document.createElement("div");
2132
2518
  cursorEl.className = `rt-cursor rt-cursor-${remoteDeviceId}`;
2133
2519
  cursorEl.style.position = "absolute";
2134
2520
  cursorEl.style.width = "10px";
2135
2521
  cursorEl.style.height = "10px";
2136
2522
  cursorEl.style.borderRadius = "50%";
2137
- cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
2523
+ cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
2138
2524
  cursorEl.style.pointerEvents = "none";
2139
2525
  container.appendChild(cursorEl);
2526
+ remoteCursors.set(cursorKey, cursorEl);
2140
2527
  }
2141
2528
  const box = container.getBoundingClientRect();
2142
2529
  cursorEl.style.left = payload.x * box.width + "px";
2143
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));
2144
2540
  });
2145
2541
  this.subscribe("collab/+/crdt", (payload, topic) => {
2146
2542
  if (payload.deviceId === this.deviceId) return;
@@ -2158,6 +2554,14 @@ function attachCollab(clientProto) {
2158
2554
  }
2159
2555
  });
2160
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
+ };
2161
2565
  };
2162
2566
  }
2163
2567