dolphin-client 1.0.5 → 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");
@@ -1394,7 +1614,8 @@ function attachDOMBinding(clientProto) {
1394
1614
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1395
1615
  el.value = typeof result === "object" ? result.value !== void 0 ? result.value : "" : result;
1396
1616
  } else {
1397
- 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);
1398
1619
  }
1399
1620
  }
1400
1621
  }
@@ -1611,11 +1832,158 @@ function attachDOMBinding(clientProto) {
1611
1832
  }
1612
1833
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1613
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);
1614
1837
  } else {
1615
- el.innerHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : processedPayload;
1838
+ const rawHTML = typeof processedPayload === "object" ? processedPayload.html || processedPayload.text || JSON.stringify(processedPayload) : String(processedPayload);
1839
+ el.innerHTML = sanitizeHTML(rawHTML);
1616
1840
  }
1617
1841
  });
1618
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
+ };
1619
1987
  }
1620
1988
 
1621
1989
  // src/offline.ts
@@ -1693,6 +2061,10 @@ var DolphinOffline = class {
1693
2061
  const store = transaction.objectStore("cache");
1694
2062
  store.put({ data, timestamp: Date.now() }, key);
1695
2063
  transaction.oncomplete = () => resolve();
2064
+ transaction.onerror = () => {
2065
+ console.warn("[Dolphin Offline] setCache write failed for key:", key);
2066
+ resolve();
2067
+ };
1696
2068
  } catch {
1697
2069
  resolve();
1698
2070
  }
@@ -1715,6 +2087,11 @@ var DolphinOffline = class {
1715
2087
  const store = transaction.objectStore("mutations");
1716
2088
  store.add(mutation);
1717
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
+ };
1718
2095
  } catch {
1719
2096
  resolve();
1720
2097
  }
@@ -1747,6 +2124,10 @@ var DolphinOffline = class {
1747
2124
  const store = transaction.objectStore("mutations");
1748
2125
  store.delete(id);
1749
2126
  transaction.oncomplete = () => resolve();
2127
+ transaction.onerror = () => {
2128
+ console.warn("[Dolphin Offline] removeMutation failed for id:", id);
2129
+ resolve();
2130
+ };
1750
2131
  } catch {
1751
2132
  resolve();
1752
2133
  }
@@ -2092,6 +2473,9 @@ function attachDragDrop(clientProto) {
2092
2473
  function attachCollab(clientProto) {
2093
2474
  clientProto._initCollab = function() {
2094
2475
  if (typeof document === "undefined") return;
2476
+ const remoteCursors = /* @__PURE__ */ new Map();
2477
+ const cursorStaleTimers = /* @__PURE__ */ new Map();
2478
+ const CURSOR_STALE_MS = 5e3;
2095
2479
  this.addDomListener(document, "mousemove", (e) => {
2096
2480
  const shareContainers = document.querySelectorAll("[data-rt-cursor-share]");
2097
2481
  shareContainers.forEach((container) => {
@@ -2128,6 +2512,7 @@ function attachCollab(clientProto) {
2128
2512
  if (e.target._typingTimer) clearTimeout(e.target._typingTimer);
2129
2513
  e.target._typingTimer = setTimeout(() => {
2130
2514
  e.target._isTyping = false;
2515
+ e.target._typingTimer = null;
2131
2516
  publishTyping(false);
2132
2517
  }, 2e3);
2133
2518
  });
@@ -2151,21 +2536,32 @@ function attachCollab(clientProto) {
2151
2536
  if (remoteDeviceId === this.deviceId) return;
2152
2537
  const container = document.querySelector(`[data-rt-cursor-share="${room}"]`);
2153
2538
  if (!container) return;
2154
- let cursorEl = container.querySelector(`.rt-cursor-${remoteDeviceId}`);
2155
- if (!cursorEl) {
2539
+ const cursorKey = `${room}::${remoteDeviceId}`;
2540
+ let cursorEl = remoteCursors.get(cursorKey);
2541
+ if (!cursorEl || !document.contains(cursorEl)) {
2156
2542
  cursorEl = document.createElement("div");
2157
2543
  cursorEl.className = `rt-cursor rt-cursor-${remoteDeviceId}`;
2158
2544
  cursorEl.style.position = "absolute";
2159
2545
  cursorEl.style.width = "10px";
2160
2546
  cursorEl.style.height = "10px";
2161
2547
  cursorEl.style.borderRadius = "50%";
2162
- cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
2548
+ cursorEl.style.backgroundColor = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
2163
2549
  cursorEl.style.pointerEvents = "none";
2164
2550
  container.appendChild(cursorEl);
2551
+ remoteCursors.set(cursorKey, cursorEl);
2165
2552
  }
2166
2553
  const box = container.getBoundingClientRect();
2167
2554
  cursorEl.style.left = payload.x * box.width + "px";
2168
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));
2169
2565
  });
2170
2566
  this.subscribe("collab/+/crdt", (payload, topic) => {
2171
2567
  if (payload.deviceId === this.deviceId) return;
@@ -2183,6 +2579,14 @@ function attachCollab(clientProto) {
2183
2579
  }
2184
2580
  });
2185
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
+ };
2186
2590
  };
2187
2591
  }
2188
2592