@cocreate/elements 1.46.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ import Actions from "@cocreate/actions";
2
+ import Observer from "@cocreate/observer";
3
+ import Socket from "@cocreate/socket-client";
4
+ import { dotNotationToObject, getValueFromObject, queryData, uuid } from "@cocreate/utils";
5
+
6
+ const socketOnSelector = "[socket-on]";
7
+ const socketOnElements = new WeakMap();
8
+ const socketOnListeners = new Map();
9
+ const socketStatusSelector = "[socket-status]";
10
+ const socketStatusElements = new Set();
11
+ let socketStatusListenersInitialized = false;
12
+
13
+ function getSocketScope(element, form) {
14
+ const scopedElement = element?.closest("[socket]");
15
+ if (scopedElement && scopedElement !== element) return scopedElement;
16
+ return form || scopedElement || element;
17
+ }
18
+
19
+ function getSocketFields(scope, element) {
20
+ if (!scope) return element ? [element] : [];
21
+
22
+ const fields = [];
23
+ if (scope.matches?.("[socket-key], [send]")) fields.push(scope);
24
+ fields.push(...scope.querySelectorAll?.("[socket-key], [send]") || []);
25
+
26
+ return fields.filter((field) => {
27
+ if (field.closest("[template]")) return false;
28
+ const key = field.getAttribute("socket-key") || field.getAttribute("send");
29
+ if (!key) return false;
30
+
31
+ // Keep nested socket scopes independent, just like API endpoint groups.
32
+ const fieldScope = field.closest("[socket]");
33
+ return !fieldScope || fieldScope === scope;
34
+ });
35
+ }
36
+
37
+ async function getData({ element, form, scope }) {
38
+ const fields = getSocketFields(scope, element);
39
+ const flatData = {};
40
+
41
+ for (const field of fields) {
42
+ const key = field.getAttribute("socket-key") || field.getAttribute("send");
43
+ if (!key || typeof field.getValue !== "function") continue;
44
+ flatData[key] = await field.getValue();
45
+ }
46
+
47
+ return dotNotationToObject(flatData);
48
+ }
49
+
50
+ function dispatchSocketError(element, form, error) {
51
+ element?.dispatchEvent?.(
52
+ new CustomEvent("socketerror", {
53
+ bubbles: true,
54
+ detail: { error, element, form }
55
+ })
56
+ );
57
+ }
58
+
59
+ function getResponseError(response) {
60
+ if (!response?.error && response?.success !== false && response?.ai?.status !== "error") return null;
61
+ return new Error(
62
+ response.ai?.message || response.error || "Socket request failed."
63
+ );
64
+ }
65
+
66
+ async function send({ element, form, continue: next, abort }) {
67
+ const scope = getSocketScope(element, form);
68
+ const method = scope?.getAttribute?.("socket") || element?.getAttribute?.("socket");
69
+ const checkbox = element?.matches?.('input[type="checkbox"]') ? element : null;
70
+ const previousChecked = checkbox ? !checkbox.checked : null;
71
+ const wasDisabled = checkbox?.disabled;
72
+ if (checkbox) checkbox.disabled = true;
73
+
74
+ try {
75
+ const data = await getData({ element, form, scope });
76
+ const request = { ...data };
77
+ if (method) request.method = method;
78
+ if (!request.uuid) request.uuid = uuid();
79
+ const isAIChat = request.method === "ai.chat";
80
+ if (isAIChat) {
81
+ console.info("CoCreate AI socket request", {
82
+ uuid: request.uuid,
83
+ conversation_id: request.ai?.conversation_id,
84
+ user_message_id: request.ai?.user_message_id
85
+ });
86
+ }
87
+
88
+ const response = await Socket.send(request);
89
+ if (response?.uuid !== request.uuid) {
90
+ throw new Error("Socket response UUID did not match the request.");
91
+ }
92
+
93
+ if (isAIChat) {
94
+ console.info("CoCreate AI socket response", {
95
+ uuid: request.uuid,
96
+ response_uuid: response.uuid,
97
+ status: response.ai?.status || response.status,
98
+ user_message_id: response.ai?.user_message_id
99
+ });
100
+ }
101
+ const responseError = getResponseError(response);
102
+ if (responseError) {
103
+ if (checkbox) checkbox.checked = previousChecked;
104
+ dispatchSocketError(element, form, responseError);
105
+ abort?.();
106
+ return;
107
+ }
108
+ next?.();
109
+ } catch (error) {
110
+ if (checkbox) checkbox.checked = previousChecked;
111
+ dispatchSocketError(element, form, error);
112
+ abort?.();
113
+ } finally {
114
+ if (checkbox) checkbox.disabled = wasDisabled;
115
+ }
116
+ }
117
+
118
+ function parseSocketMatches(element) {
119
+ const value = element.getAttribute("socket-on-matches");
120
+ if (!value) return {};
121
+
122
+ try {
123
+ const matches = JSON.parse(value);
124
+ if (!matches || typeof matches !== "object" || Array.isArray(matches)) {
125
+ return null;
126
+ }
127
+ return matches;
128
+ } catch (error) {
129
+ console.warn("CoCreate: socket-on-matches must be a JSON object.", error);
130
+ return null;
131
+ }
132
+ }
133
+
134
+ function normalizeSocketMatches(query, prefix = "") {
135
+ const normalized = {};
136
+ const logicalOperators = new Set(["$and", "$or", "$nor"]);
137
+
138
+ for (const [key, value] of Object.entries(query)) {
139
+ if (!prefix && logicalOperators.has(key)) {
140
+ normalized[key] = Array.isArray(value)
141
+ ? value.map((entry) => normalizeSocketMatches(entry))
142
+ : value;
143
+ continue;
144
+ }
145
+
146
+ const path = prefix ? `${prefix}.${key}` : key;
147
+ if (value && typeof value === "object" && !Array.isArray(value)) {
148
+ const properties = Object.keys(value);
149
+ if (properties.some((property) => property.startsWith("$"))) {
150
+ normalized[path] = value;
151
+ } else {
152
+ Object.assign(normalized, normalizeSocketMatches(value, path));
153
+ }
154
+ } else {
155
+ normalized[path] = value;
156
+ }
157
+ }
158
+
159
+ return normalized;
160
+ }
161
+
162
+ function matchesSocketMessage(element, data) {
163
+ const matches = parseSocketMatches(element);
164
+ if (!matches) return false;
165
+ try {
166
+ return queryData(data, normalizeSocketMatches(matches));
167
+ } catch (error) {
168
+ console.warn("CoCreate: unable to evaluate socket-on-matches query.", error);
169
+ return false;
170
+ }
171
+ }
172
+
173
+ async function setSocketOnValue(element, data) {
174
+ if (!element.isConnected || !matchesSocketMessage(element, data)) return;
175
+ const key = element.getAttribute("socket-on-key");
176
+ if (!key || typeof element.setValue !== "function") return;
177
+
178
+ const value = getValueFromObject(data, key);
179
+ if (value === undefined) return;
180
+ if (typeof element.renderValue === "function") {
181
+ await element.renderValue({ object: value });
182
+ return;
183
+ }
184
+ await element.setValue(value);
185
+ }
186
+
187
+ function setSocketStatus(element, status) {
188
+ if (!element.isConnected) return;
189
+ const label = element.getAttribute(`socket-status-${status}`);
190
+ if (label) element.textContent = label;
191
+ element.classList.toggle("connected", status === "connected");
192
+ element.classList.toggle("disconnected", status === "disconnected");
193
+ element.classList.remove("error");
194
+ }
195
+
196
+ function registerSocketStatus(element) {
197
+ if (!element?.matches?.(socketStatusSelector)) return;
198
+ socketStatusElements.add(element);
199
+ if (!socketStatusListenersInitialized) {
200
+ Socket.listen("connect", () => {
201
+ for (const target of socketStatusElements) {
202
+ setSocketStatus(target, "connected");
203
+ }
204
+ });
205
+ Socket.listen("disconnect", () => {
206
+ for (const target of socketStatusElements) {
207
+ setSocketStatus(target, "disconnected");
208
+ }
209
+ });
210
+ socketStatusListenersInitialized = true;
211
+ }
212
+ if (Socket.connected) setSocketStatus(element, "connected");
213
+ }
214
+
215
+ function initializeSocketStatus(root = document) {
216
+ if (root.matches?.(socketStatusSelector)) registerSocketStatus(root);
217
+ root.querySelectorAll?.(socketStatusSelector).forEach(registerSocketStatus);
218
+ }
219
+
220
+ function removeSocketStatus(root) {
221
+ if (root.matches?.(socketStatusSelector)) socketStatusElements.delete(root);
222
+ root.querySelectorAll?.(socketStatusSelector).forEach(element => {
223
+ socketStatusElements.delete(element);
224
+ });
225
+ }
226
+
227
+ function registerSocketOn(element) {
228
+ if (!element?.matches?.(socketOnSelector)) return;
229
+
230
+ const previousMethod = socketOnElements.get(element);
231
+ const method = element.getAttribute("socket-on")?.trim();
232
+ if (previousMethod && previousMethod !== method) {
233
+ socketOnListeners.get(previousMethod)?.elements.delete(element);
234
+ socketOnElements.delete(element);
235
+ }
236
+ if (!method || socketOnElements.has(element)) return;
237
+
238
+ let listener = socketOnListeners.get(method);
239
+ if (!listener) {
240
+ listener = { elements: new Set() };
241
+ listener.callback = (data) => {
242
+ for (const target of listener.elements) {
243
+ if (target.getAttribute("socket-on") !== method) continue;
244
+ setSocketOnValue(target, data).catch((error) => {
245
+ console.error("CoCreate: unable to set socket listener value.", error);
246
+ });
247
+ }
248
+ };
249
+ socketOnListeners.set(method, listener);
250
+ Socket.listen(method, listener.callback);
251
+ }
252
+
253
+ listener.elements.add(element);
254
+ socketOnElements.set(element, method);
255
+ }
256
+
257
+ function initializeSocketOn(root = document) {
258
+ if (root.matches?.(socketOnSelector)) registerSocketOn(root);
259
+ root.querySelectorAll?.(socketOnSelector).forEach(registerSocketOn);
260
+ }
261
+
262
+ function removeSocketOn(root) {
263
+ const elements = [];
264
+ if (root.matches?.(socketOnSelector)) elements.push(root);
265
+ elements.push(...root.querySelectorAll?.(socketOnSelector) || []);
266
+ for (const element of elements) {
267
+ const method = socketOnElements.get(element);
268
+ if (method) socketOnListeners.get(method)?.elements.delete(element);
269
+ socketOnElements.delete(element);
270
+ }
271
+ }
272
+
273
+ Actions.init({ name: "send", callback: send });
274
+
275
+ initializeSocketOn();
276
+ initializeSocketStatus();
277
+
278
+ Observer.init({
279
+ name: "CoCreateElementsSocketOnAdded",
280
+ types: ["addedNodes"],
281
+ selector: socketOnSelector,
282
+ callback: (mutation) => initializeSocketOn(mutation.target)
283
+ });
284
+
285
+ Observer.init({
286
+ name: "CoCreateElementsSocketOnRemoved",
287
+ types: ["removedNodes"],
288
+ selector: socketOnSelector,
289
+ callback: (mutation) => removeSocketOn(mutation.target)
290
+ });
291
+
292
+ Observer.init({
293
+ name: "CoCreateElementsSocketOnAttribute",
294
+ types: ["attributes"],
295
+ attributeFilter: ["socket-on"],
296
+ selector: socketOnSelector,
297
+ callback: (mutation) => registerSocketOn(mutation.target)
298
+ });
299
+
300
+ Observer.init({
301
+ name: "CoCreateElementsSocketStatusAdded",
302
+ types: ["addedNodes"],
303
+ selector: socketStatusSelector,
304
+ callback: (mutation) => initializeSocketStatus(mutation.target)
305
+ });
306
+
307
+ Observer.init({
308
+ name: "CoCreateElementsSocketStatusRemoved",
309
+ types: ["removedNodes"],
310
+ selector: socketStatusSelector,
311
+ callback: (mutation) => removeSocketStatus(mutation.target)
312
+ });
313
+
314
+ export { getData as getSocketData, send as sendSocketMessage };
315
+ export {
316
+ initializeSocketOn,
317
+ registerSocketOn,
318
+ removeSocketOn,
319
+ setSocketOnValue
320
+ };
321
+ export default {
322
+ getData,
323
+ initializeSocketOn,
324
+ registerSocketOn,
325
+ removeSocketOn,
326
+ send,
327
+ setSocketOnValue
328
+ };
package/src/srcHandler.js CHANGED
@@ -1,8 +1,18 @@
1
1
  import observer from "@cocreate/observer";
2
2
  import { getRelativePath, ObjectId } from "@cocreate/utils";
3
3
 
4
- const selector =
5
- "[src]:not(img, video, audio, script, input, iframe, frame, link, source), [source], [stream]";
4
+ const sourceTagNames = new Set([
5
+ "IMG",
6
+ "VIDEO",
7
+ "AUDIO",
8
+ "SCRIPT",
9
+ "INPUT",
10
+ "IFRAME",
11
+ "FRAME",
12
+ "LINK",
13
+ "SOURCE"
14
+ ]);
15
+ const selector = "[src], [source], [stream]";
6
16
 
7
17
  const initializing = new WeakMap();
8
18
 
@@ -14,9 +24,19 @@ const initializing = new WeakMap();
14
24
  */
15
25
  function isSameOrigin(urlStr) {
16
26
  try {
17
- // Resolve the URL against the current document's origin.
18
- const url = new URL(urlStr, window.location.origin);
19
- return url.origin === window.location.origin;
27
+ let origin = window.location.origin;
28
+
29
+ // Sandboxed srcdoc/about:blank documents have an opaque URL even when
30
+ // same-origin access was explicitly granted. In that case, parent
31
+ // access succeeds only when the browser considers the frame trusted.
32
+ if (origin === "null" && window.parent !== window) {
33
+ origin = window.parent.location.origin;
34
+ }
35
+
36
+ if (!origin || origin === "null") return false;
37
+
38
+ const url = new URL(urlStr, origin);
39
+ return url.origin === origin;
20
40
  } catch (err) {
21
41
  console.error("Invalid URL format:", urlStr, err);
22
42
  return false;
@@ -24,6 +44,8 @@ function isSameOrigin(urlStr) {
24
44
  }
25
45
 
26
46
  function init(elements) {
47
+ if (elements?.tagName && sourceTagNames.has(elements.tagName)) return;
48
+
27
49
  if (elements && !Array.isArray(elements)) elements = [elements];
28
50
 
29
51
  if (!elements) elements = document.querySelectorAll(selector);
@@ -33,6 +55,8 @@ function init(elements) {
33
55
 
34
56
  async function initElements(elements) {
35
57
  for (let element of elements) {
58
+ if (sourceTagNames.has(element.tagName)) continue;
59
+
36
60
  let src =
37
61
  element.getAttribute("src") ||
38
62
  element.getAttribute("source") ||
@@ -41,27 +65,45 @@ async function initElements(elements) {
41
65
  if (!src || /{{\s*([\w\W]+)\s*}}/g.test(src)) continue;
42
66
 
43
67
  let initialize = initializing.get(element);
68
+
69
+ if (element.hasAttribute("rendered")) {
70
+ element.removeAttribute("rendered");
71
+ initializing.set(element, { src });
72
+ continue
73
+ }
44
74
  if (initialize && initialize.src === src) continue;
45
75
 
46
- initializing.set(element, { src });
76
+
77
+ const request = { src };
78
+ initializing.set(element, request);
79
+ const isCurrentRequest = () =>
80
+ initializing.get(element) === request &&
81
+ (element.getAttribute("src") ||
82
+ element.getAttribute("source") ||
83
+ element.getAttribute("stream")) === src;
47
84
  try {
48
85
  // --- SECURITY FIX: Conditional Sensitive Headers ---
49
86
  // Only attach the local state header if fetching from the same origin.
50
87
  let headers = {};
51
88
  if (isSameOrigin(src)) {
52
- let state = localStorage.getItem("state");
89
+ let state = sessionStorage.getItem("state");
53
90
  if (state) {
54
91
  headers["X-State"] = state;
55
92
  }
56
93
  }
57
94
 
58
95
  let response = await fetch(src, { headers });
96
+ if (!isCurrentRequest()) continue;
59
97
 
60
98
  if (response.status === 404 || response.status === 403) {
61
99
  element.setAttribute("error", response.status);
62
100
  continue;
63
101
  }
64
102
 
103
+ if (response.ok && ["304", "403", "404"].includes(element.getAttribute("error"))) {
104
+ element.removeAttribute("error");
105
+ }
106
+
65
107
  if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
66
108
  // Media streams can safely load cross-origin without risk of DOM-based XSS
67
109
  initMediaSource(element, src);
@@ -74,24 +116,22 @@ async function initElements(elements) {
74
116
  continue;
75
117
  }
76
118
 
77
- if (element.hasAttribute("rendered")) {
78
- element.removeAttribute("rendered");
79
- }
80
119
  let text = await response.text();
120
+ if (!isCurrentRequest()) continue;
81
121
  if (text) {
82
- let path = "";
83
- let pathElement = element.closest("[path]");
84
- if (pathElement) {
85
- path =
86
- pathElement.getAttribute("path") ||
87
- getRelativePath();
88
- } else {
89
- path = getRelativePath();
90
- }
91
- // Replace $relativePath in the fetched text
92
- if (path) {
93
- text = text.replaceAll(/\$relativePath\/?/g, path);
94
- }
122
+ // let path = "";
123
+ // let pathElement = element.closest("[path]");
124
+ // if (pathElement) {
125
+ // path =
126
+ // pathElement.getAttribute("path") ||
127
+ // getRelativePath();
128
+ // } else {
129
+ // path = getRelativePath();
130
+ // }
131
+ // // Replace $relativePath in the fetched text
132
+ // if (path) {
133
+ // text = text.replaceAll(/\$relativePath\/?/g, path);
134
+ // }
95
135
  // Replace ObjectId() with a new ObjectId
96
136
  text = text.replaceAll("ObjectId()", () => {
97
137
  return ObjectId().toString();
@@ -278,8 +318,9 @@ function base64ToArrayBuffer(base64) {
278
318
  observer.init({
279
319
  name: "CoCreateSrc",
280
320
  types: ["addedNodes"],
281
- selector: selector,
321
+ selector: "[src], [source], [stream]",
282
322
  callback: function (mutation) {
323
+ if (mutation.move) return;
283
324
  init(mutation.target);
284
325
  }
285
326
  });
@@ -300,15 +341,10 @@ observer.init({
300
341
  name: "CoCreateSrcAttributes",
301
342
  types: ["attributes"],
302
343
  attributeFilter: ["src"],
303
- selector: selector,
344
+ selector: "[src], [source], [stream]",
304
345
  callback: function (mutation) {
305
- if (
306
- mutation.oldValue !==
307
- mutation.target.getAttribute(mutation.attributeName)
308
- ) {
309
- init(mutation.target);
310
- }
346
+ init(mutation.target);
311
347
  }
312
348
  });
313
349
 
314
- init();
350
+ init();
@@ -2,102 +2,102 @@ import socket from "@cocreate/socket-client";
2
2
 
3
3
  /**
4
4
  * Canonical scope attributes included in socket server subscription channel keys.
5
- * Excludes field-level "key" and "$filter" so document and collection channels remain cohesive.
5
+ * Excludes field-level "key" and "filter" so document and collection channels remain cohesive.
6
6
  */
7
7
  const SUBSCRIPTION_ATTRIBUTES = [
8
- "host",
9
- "organization_id",
8
+ "namespace",
9
+ "room",
10
10
  "storage",
11
11
  "database",
12
12
  "array",
13
- "index",
14
13
  "object"
15
14
  ];
16
15
 
17
- /**
18
- * Derives all target subscription channel keys for write operations (create, update, delete).
19
- * Returns an array containing both the specific document scope key (with ID)
20
- * and the generic scope key (without ID) to notify both single-item and list subscribers.
21
- *
22
- * @param {Object} dataKey - Client dataKey or CRUD payload object
23
- * @returns {string[]} Array of JSON-stringified subscription key channel strings
24
- */
25
- export function deriveSubscriptionKeys(dataKey) {
26
- if (!dataKey) return [];
27
- const rawObj = dataKey.object || dataKey;
28
-
29
- const baseSubObj = {};
16
+ export function getSubscription(data) {
17
+ const object = {};
18
+
19
+ for (const attribute of SUBSCRIPTION_ATTRIBUTES) {
20
+ let value = data[attribute];
21
+
22
+ if (attribute === "object") {
23
+ if (Array.isArray(value)) {
24
+ value = value
25
+ .map(item => typeof item === "object" ? item?._id : item)
26
+ .filter(Boolean)
27
+ .sort();
28
+ } else if (typeof value === "object" && value !== null) {
29
+ value = value._id;
30
+ }
31
+ } else if (Array.isArray(value)) {
32
+ value = [...value].sort();
33
+ }
30
34
 
31
- for (const attr of SUBSCRIPTION_ATTRIBUTES) {
32
- const value = rawObj[attr];
33
35
  if (value !== undefined && value !== null && value !== "") {
34
- baseSubObj[attr] = value;
36
+ object[attribute] = value;
35
37
  }
36
38
  }
37
39
 
38
- const keysSet = new Set();
39
-
40
- // 1. Specific Document Key (includes exact object ID if present)
41
- const sortedSpecific = Object.fromEntries(
42
- Object.entries(baseSubObj).sort(([a], [b]) => a.localeCompare(b))
40
+ const normalizedObject = Object.fromEntries(
41
+ Object.entries(object).sort(([a], [b]) => a.localeCompare(b))
43
42
  );
44
- keysSet.add(JSON.stringify(sortedSpecific));
45
-
46
- // 2. Generic Scope Key (targets list component subscribers watching the collection)
47
- if (baseSubObj.object) {
48
- const genericSubObj = { ...baseSubObj };
49
- genericSubObj.object = []; // Generalize object scope
50
43
 
51
- const sortedGeneric = Object.fromEntries(
52
- Object.entries(genericSubObj).sort(([a], [b]) => a.localeCompare(b))
53
- );
54
- keysSet.add(JSON.stringify(sortedGeneric));
55
- }
44
+ if (normalizedObject.object && !normalizedObject.array) return;
56
45
 
57
- return Array.from(keysSet);
46
+ return {
47
+ string: JSON.stringify(normalizedObject),
48
+ object: normalizedObject
49
+ };
58
50
  }
59
51
 
60
52
  /**
61
- * Sends a subscription payload to the socket server for a registered dataKey.
62
- *
63
- * @param {Object} dataKey - Generated dataKey object { string, object }
64
- * @param {Object} [data] - Optional metadata or payload overrides
65
- * @returns {Object|undefined} Payload sent to the socket
53
+ * Derives all target subscription channel keys for write operations (create, update, delete).
54
+ * Returns an array containing both the specific document scope key (with ID)
55
+ * and the generic scope key (without ID) to notify both single-item and list subscribers.
56
+ *
57
+ * @param {Object} data - CRUD data object
58
+ * @returns {string[]} Array of JSON-stringified subscription key channel strings
66
59
  */
67
- export function subscribe(dataKey, data = {}) {
68
- if (!dataKey || !dataKey.string) return;
69
-
70
- const payload = {
71
- method: "subscribe",
72
- key: dataKey.string,
73
- subscriptionKeys: deriveSubscriptionKeys(dataKey.object || dataKey),
74
- ...dataKey.object,
75
- ...data
60
+ export function deriveSubscriptionKeys(data) {
61
+ if (!data) return [];
62
+ const subscription = getSubscription(data);
63
+ if (!subscription) return [];
64
+ const baseSubObj = { ...subscription.object };
65
+ const objects = Array.isArray(baseSubObj.object)
66
+ ? baseSubObj.object
67
+ : baseSubObj.object
68
+ ? [baseSubObj.object]
69
+ : [];
70
+ delete baseSubObj.object;
71
+ const keysSet = new Set();
72
+ const addKey = (scope) => {
73
+ const sortedScope = Object.fromEntries(
74
+ Object.entries(scope).sort(([a], [b]) => a.localeCompare(b))
75
+ );
76
+ keysSet.add(JSON.stringify(sortedScope));
76
77
  };
77
78
 
78
- if (socket && typeof socket.send === "function") {
79
- socket.send(payload);
80
- }
79
+ for (const object of objects)
80
+ addKey({ ...baseSubObj, object });
81
81
 
82
- return payload;
82
+ // Collection subscribers use the same scope with the object omitted.
83
+ addKey(baseSubObj);
84
+
85
+ return Array.from(keysSet);
83
86
  }
84
87
 
85
88
  /**
86
89
  * Sends an unsubscription payload to the socket server when an element scope is unmounted.
87
- *
90
+ *
88
91
  * @param {Object} dataKey - Generated dataKey object { string, object }
89
92
  * @param {Object} [data] - Optional metadata or payload overrides
90
93
  * @returns {Object|undefined} Payload sent to the socket
91
94
  */
92
- export function unsubscribe(dataKey, data = {}) {
93
- if (!dataKey || !dataKey.string) return;
95
+ export function unsubscribe(subscription) {
96
+ if (!subscription || !subscription.string) return;
94
97
 
95
98
  const payload = {
96
99
  method: "unsubscribe",
97
- key: dataKey.string,
98
- subscriptionKeys: deriveSubscriptionKeys(dataKey.object || dataKey),
99
- ...dataKey.object,
100
- ...data
100
+ subscription: subscription.string
101
101
  };
102
102
 
103
103
  if (socket && typeof socket.send === "function") {
@@ -108,7 +108,7 @@ export function unsubscribe(dataKey, data = {}) {
108
108
  }
109
109
 
110
110
  export default {
111
+ getSubscription,
111
112
  deriveSubscriptionKeys,
112
- subscribe,
113
113
  unsubscribe
114
- };
114
+ };