browserwire 0.1.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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/cli/api/bridge.js +64 -0
- package/cli/api/openapi.js +175 -0
- package/cli/api/router.js +280 -0
- package/cli/api/swagger-ui.js +26 -0
- package/cli/discovery/classify.js +304 -0
- package/cli/discovery/compile.js +392 -0
- package/cli/discovery/enrich.js +376 -0
- package/cli/discovery/entities.js +356 -0
- package/cli/discovery/llm-client.js +352 -0
- package/cli/discovery/locators.js +326 -0
- package/cli/discovery/perceive.js +476 -0
- package/cli/discovery/session.js +930 -0
- package/cli/discovery/synthesize-workflows.js +295 -0
- package/cli/index.js +63 -0
- package/cli/manifest-store.js +140 -0
- package/cli/server.js +539 -0
- package/extension/background.js +1512 -0
- package/extension/content-script.js +491 -0
- package/extension/discovery.js +495 -0
- package/extension/executor.js +392 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +33 -0
- package/extension/shared/protocol.js +50 -0
- package/extension/sidepanel.html +277 -0
- package/extension/sidepanel.js +211 -0
- package/extension/vendor/LICENSE +22 -0
- package/extension/vendor/rrweb-record.min.js +84 -0
- package/package.json +49 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* executor.js — Action executor for the SDK
|
|
3
|
+
*
|
|
4
|
+
* Resolves locator strategies against the live DOM and executes actions
|
|
5
|
+
* (click, type, select) or reads element state.
|
|
6
|
+
*
|
|
7
|
+
* Injected as a content script alongside discovery.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Locator resolution
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Try each locator strategy in order, return the first that uniquely matches.
|
|
16
|
+
* @param {Array<{kind: string, value: string, confidence: number}>} strategies
|
|
17
|
+
* @returns {{ element: Element, usedStrategy: { kind: string, value: string } } | null}
|
|
18
|
+
*/
|
|
19
|
+
const resolveLocator = (strategies) => {
|
|
20
|
+
// Sort by confidence descending
|
|
21
|
+
const sorted = [...strategies].sort((a, b) => b.confidence - a.confidence);
|
|
22
|
+
|
|
23
|
+
for (const strategy of sorted) {
|
|
24
|
+
const result = tryStrategy(strategy);
|
|
25
|
+
if (result) {
|
|
26
|
+
return { element: result, usedStrategy: { kind: strategy.kind, value: strategy.value } };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Try a single locator strategy. Returns the element if exactly one matches.
|
|
35
|
+
*/
|
|
36
|
+
const tryStrategy = (strategy) => {
|
|
37
|
+
try {
|
|
38
|
+
switch (strategy.kind) {
|
|
39
|
+
case "css":
|
|
40
|
+
case "dom_path":
|
|
41
|
+
return tryCSS(strategy.value);
|
|
42
|
+
|
|
43
|
+
case "xpath":
|
|
44
|
+
return tryXPath(strategy.value);
|
|
45
|
+
|
|
46
|
+
case "role_name":
|
|
47
|
+
return tryRoleName(strategy.value);
|
|
48
|
+
|
|
49
|
+
case "attribute":
|
|
50
|
+
return tryAttribute(strategy.value);
|
|
51
|
+
|
|
52
|
+
case "text":
|
|
53
|
+
return tryText(strategy.value);
|
|
54
|
+
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const tryCSS = (selector) => {
|
|
64
|
+
const matches = document.querySelectorAll(selector);
|
|
65
|
+
if (matches.length === 1) return matches[0];
|
|
66
|
+
// For dom_path-style selectors, accept first visible match
|
|
67
|
+
if (matches.length > 1) {
|
|
68
|
+
for (const el of matches) {
|
|
69
|
+
if (isElementVisible(el)) return el;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tryXPath = (xpath) => {
|
|
76
|
+
// Prefix with /html if it starts with /body
|
|
77
|
+
const fullXpath = xpath.startsWith("/body") ? `/html${xpath}` : xpath;
|
|
78
|
+
const result = document.evaluate(
|
|
79
|
+
fullXpath, document, null,
|
|
80
|
+
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null
|
|
81
|
+
);
|
|
82
|
+
if (result.snapshotLength === 1) return result.snapshotItem(0);
|
|
83
|
+
if (result.snapshotLength > 1) {
|
|
84
|
+
for (let i = 0; i < result.snapshotLength; i++) {
|
|
85
|
+
const el = result.snapshotItem(i);
|
|
86
|
+
if (isElementVisible(el)) return el;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const tryRoleName = (value) => {
|
|
93
|
+
// Parse "role \"accessible name\""
|
|
94
|
+
const match = value.match(/^(\w+)\s+"(.+)"$/);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
const [, role, name] = match;
|
|
97
|
+
|
|
98
|
+
// Walk the DOM looking for matching role + name
|
|
99
|
+
const candidates = document.querySelectorAll("*");
|
|
100
|
+
let found = null;
|
|
101
|
+
let count = 0;
|
|
102
|
+
|
|
103
|
+
for (const el of candidates) {
|
|
104
|
+
const elRole = el.getAttribute("role") || getImplicitRole(el);
|
|
105
|
+
if (elRole !== role) continue;
|
|
106
|
+
|
|
107
|
+
const elName = getAccessibleName(el);
|
|
108
|
+
if (elName === name) {
|
|
109
|
+
found = el;
|
|
110
|
+
count++;
|
|
111
|
+
if (count > 1) return null; // Ambiguous
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return found;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const tryAttribute = (value) => {
|
|
119
|
+
const colonIdx = value.indexOf(":");
|
|
120
|
+
if (colonIdx === -1) return null;
|
|
121
|
+
const attr = value.slice(0, colonIdx);
|
|
122
|
+
const attrVal = value.slice(colonIdx + 1);
|
|
123
|
+
|
|
124
|
+
const matches = document.querySelectorAll(`[${attr}="${CSS.escape(attrVal)}"]`);
|
|
125
|
+
if (matches.length === 1) return matches[0];
|
|
126
|
+
if (matches.length > 1) {
|
|
127
|
+
for (const el of matches) {
|
|
128
|
+
if (isElementVisible(el)) return el;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const tryText = (value) => {
|
|
135
|
+
const walker = document.createTreeWalker(
|
|
136
|
+
document.body, NodeFilter.SHOW_TEXT,
|
|
137
|
+
{
|
|
138
|
+
acceptNode(node) {
|
|
139
|
+
const text = (node.textContent || "").trim();
|
|
140
|
+
if (text === value) return NodeFilter.FILTER_ACCEPT;
|
|
141
|
+
return NodeFilter.FILTER_REJECT;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const textNode = walker.nextNode();
|
|
147
|
+
if (!textNode) return null;
|
|
148
|
+
// Check no second match
|
|
149
|
+
if (walker.nextNode()) return null;
|
|
150
|
+
return textNode.parentElement;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Helpers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const IMPLICIT_ROLES = {
|
|
158
|
+
button: "button", a: "link", textarea: "textbox",
|
|
159
|
+
nav: "navigation", main: "main", form: "form",
|
|
160
|
+
table: "table", ul: "list", ol: "list", li: "listitem",
|
|
161
|
+
dialog: "dialog", article: "article", section: "region",
|
|
162
|
+
aside: "complementary", header: "banner", footer: "contentinfo",
|
|
163
|
+
select: "combobox", details: "group", summary: "button"
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const INPUT_ROLES = {
|
|
167
|
+
text: "textbox", search: "searchbox", email: "textbox",
|
|
168
|
+
tel: "textbox", url: "textbox", password: "textbox",
|
|
169
|
+
number: "spinbutton", range: "slider",
|
|
170
|
+
checkbox: "checkbox", radio: "radio",
|
|
171
|
+
button: "button", submit: "button", reset: "button"
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const getImplicitRole = (el) => {
|
|
175
|
+
const tag = el.tagName.toLowerCase();
|
|
176
|
+
if (tag === "input") {
|
|
177
|
+
return INPUT_ROLES[(el.type || "text").toLowerCase()] || null;
|
|
178
|
+
}
|
|
179
|
+
if (tag.match(/^h[1-6]$/)) return "heading";
|
|
180
|
+
return IMPLICIT_ROLES[tag] || null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const getAccessibleName = (el) => {
|
|
184
|
+
return (
|
|
185
|
+
el.getAttribute("aria-label") ||
|
|
186
|
+
el.getAttribute("title") ||
|
|
187
|
+
el.getAttribute("alt") ||
|
|
188
|
+
el.getAttribute("placeholder") ||
|
|
189
|
+
el.textContent?.trim().slice(0, 100) ||
|
|
190
|
+
""
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const isElementVisible = (el) => {
|
|
195
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
196
|
+
const style = window.getComputedStyle(el);
|
|
197
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
198
|
+
const rect = el.getBoundingClientRect();
|
|
199
|
+
return rect.width > 0 && rect.height > 0;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Action execution
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Execute an action on a resolved element.
|
|
208
|
+
*/
|
|
209
|
+
const executeAction = (element, interactionKind, inputs = {}) => {
|
|
210
|
+
const kind = (interactionKind || "click").toLowerCase();
|
|
211
|
+
|
|
212
|
+
switch (kind) {
|
|
213
|
+
case "click":
|
|
214
|
+
case "navigate":
|
|
215
|
+
element.click();
|
|
216
|
+
return { ok: true, action: "clicked" };
|
|
217
|
+
|
|
218
|
+
case "type": {
|
|
219
|
+
const text = inputs.text || inputs.value || Object.values(inputs)[0] || "";
|
|
220
|
+
element.focus();
|
|
221
|
+
// Clear existing value
|
|
222
|
+
if ("value" in element) {
|
|
223
|
+
element.value = "";
|
|
224
|
+
}
|
|
225
|
+
// Dispatch input events character by character for framework compatibility
|
|
226
|
+
for (const char of text) {
|
|
227
|
+
element.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
|
|
228
|
+
if ("value" in element) {
|
|
229
|
+
element.value += char;
|
|
230
|
+
}
|
|
231
|
+
element.dispatchEvent(new InputEvent("input", { data: char, inputType: "insertText", bubbles: true }));
|
|
232
|
+
element.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
|
|
233
|
+
}
|
|
234
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
235
|
+
return { ok: true, action: "typed", length: text.length };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "select": {
|
|
239
|
+
const value = inputs.value || inputs.selected_option || Object.values(inputs)[0] || "";
|
|
240
|
+
if ("value" in element) {
|
|
241
|
+
element.value = value;
|
|
242
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
243
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, action: "selected", value };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
default:
|
|
249
|
+
// Fallback to click
|
|
250
|
+
element.click();
|
|
251
|
+
return { ok: true, action: "clicked" };
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// State reading
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read the state of a resolved element.
|
|
261
|
+
*/
|
|
262
|
+
const readElementState = (element) => {
|
|
263
|
+
const rect = element.getBoundingClientRect();
|
|
264
|
+
const style = window.getComputedStyle(element);
|
|
265
|
+
const tag = element.tagName.toLowerCase();
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
visible: isElementVisible(element),
|
|
269
|
+
tag,
|
|
270
|
+
text: (element.textContent || "").trim().slice(0, 500),
|
|
271
|
+
value: "value" in element ? element.value : undefined,
|
|
272
|
+
checked: "checked" in element ? element.checked : undefined,
|
|
273
|
+
disabled: element.disabled || element.getAttribute("aria-disabled") === "true",
|
|
274
|
+
attributes: Object.fromEntries(
|
|
275
|
+
Array.from(element.attributes).map((a) => [a.name, a.value])
|
|
276
|
+
),
|
|
277
|
+
rect: {
|
|
278
|
+
x: Math.round(rect.x),
|
|
279
|
+
y: Math.round(rect.y),
|
|
280
|
+
width: Math.round(rect.width),
|
|
281
|
+
height: Math.round(rect.height)
|
|
282
|
+
},
|
|
283
|
+
role: element.getAttribute("role") || getImplicitRole(element),
|
|
284
|
+
name: getAccessibleName(element)
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Message listener
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
293
|
+
if (!message || message.source !== "background") return false;
|
|
294
|
+
|
|
295
|
+
if (message.command === "execute_action") {
|
|
296
|
+
try {
|
|
297
|
+
const { strategies, interactionKind, inputs } = message.payload;
|
|
298
|
+
const resolved = resolveLocator(strategies || []);
|
|
299
|
+
|
|
300
|
+
if (!resolved) {
|
|
301
|
+
sendResponse({
|
|
302
|
+
ok: false,
|
|
303
|
+
error: "ERR_TARGET_NOT_FOUND",
|
|
304
|
+
message: "No locator strategy matched a unique element"
|
|
305
|
+
});
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const result = executeAction(resolved.element, interactionKind, inputs);
|
|
310
|
+
sendResponse({
|
|
311
|
+
ok: true,
|
|
312
|
+
result,
|
|
313
|
+
usedStrategy: resolved.usedStrategy
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
sendResponse({
|
|
317
|
+
ok: false,
|
|
318
|
+
error: "ERR_EXECUTION_FAILED",
|
|
319
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (message.command === "evaluate_state_signals") {
|
|
326
|
+
try {
|
|
327
|
+
const { signals } = message.payload;
|
|
328
|
+
const results = {};
|
|
329
|
+
for (const signal of (signals || [])) {
|
|
330
|
+
const key = `${signal.kind}:${signal.value}`;
|
|
331
|
+
try {
|
|
332
|
+
if (signal.kind === "selector_exists") {
|
|
333
|
+
results[key] = document.querySelector(signal.value) !== null;
|
|
334
|
+
} else if (signal.kind === "text_match") {
|
|
335
|
+
const el = signal.selector ? document.querySelector(signal.selector) : document.body;
|
|
336
|
+
if (el) {
|
|
337
|
+
results[key] = new RegExp(signal.value).test((el.textContent || "").trim());
|
|
338
|
+
} else {
|
|
339
|
+
results[key] = false;
|
|
340
|
+
}
|
|
341
|
+
} else if (signal.kind === "url_pattern") {
|
|
342
|
+
results[key] = new RegExp(signal.value).test(window.location.pathname);
|
|
343
|
+
} else {
|
|
344
|
+
results[key] = false;
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
results[key] = false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
sendResponse({ ok: true, results });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
sendResponse({
|
|
353
|
+
ok: false,
|
|
354
|
+
error: "ERR_SIGNAL_EVAL_FAILED",
|
|
355
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (message.command === "read_entity") {
|
|
362
|
+
try {
|
|
363
|
+
const { strategies } = message.payload;
|
|
364
|
+
const resolved = resolveLocator(strategies || []);
|
|
365
|
+
|
|
366
|
+
if (!resolved) {
|
|
367
|
+
sendResponse({
|
|
368
|
+
ok: false,
|
|
369
|
+
error: "ERR_TARGET_NOT_FOUND",
|
|
370
|
+
message: "No locator strategy matched a unique element"
|
|
371
|
+
});
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const state = readElementState(resolved.element);
|
|
376
|
+
sendResponse({
|
|
377
|
+
ok: true,
|
|
378
|
+
state,
|
|
379
|
+
usedStrategy: resolved.usedStrategy
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
sendResponse({
|
|
383
|
+
ok: false,
|
|
384
|
+
error: "ERR_READ_FAILED",
|
|
385
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return false;
|
|
392
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "BrowserWire",
|
|
4
|
+
"description": "Boilerplate side panel extension for BrowserWire local connectivity.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"permissions": ["sidePanel", "tabs", "scripting", "activeTab", "webRequest"],
|
|
7
|
+
"host_permissions": ["<all_urls>"],
|
|
8
|
+
"background": {
|
|
9
|
+
"service_worker": "background.js",
|
|
10
|
+
"type": "module"
|
|
11
|
+
},
|
|
12
|
+
"content_scripts": [
|
|
13
|
+
{
|
|
14
|
+
"matches": ["http://*/*", "https://*/*"],
|
|
15
|
+
"js": ["vendor/rrweb-record.min.js", "content-script.js"],
|
|
16
|
+
"run_at": "document_start"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"matches": ["http://*/*", "https://*/*"],
|
|
20
|
+
"js": ["discovery.js", "executor.js"],
|
|
21
|
+
"run_at": "document_idle"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"action": {
|
|
25
|
+
"default_title": "Open BrowserWire"
|
|
26
|
+
},
|
|
27
|
+
"side_panel": {
|
|
28
|
+
"default_path": "sidepanel.html"
|
|
29
|
+
},
|
|
30
|
+
"content_security_policy": {
|
|
31
|
+
"extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* ws://localhost:*;"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = "0.1.0";
|
|
2
|
+
|
|
3
|
+
export const MessageType = Object.freeze({
|
|
4
|
+
HELLO: "hello",
|
|
5
|
+
HELLO_ACK: "hello_ack",
|
|
6
|
+
PING: "ping",
|
|
7
|
+
PONG: "pong",
|
|
8
|
+
STATUS: "status",
|
|
9
|
+
ERROR: "error",
|
|
10
|
+
DISCOVERY_SCAN: "discovery_scan",
|
|
11
|
+
DISCOVERY_SNAPSHOT: "discovery_snapshot",
|
|
12
|
+
DISCOVERY_ACK: "discovery_ack",
|
|
13
|
+
DISCOVERY_SESSION_START: "discovery_session_start",
|
|
14
|
+
DISCOVERY_SESSION_STOP: "discovery_session_stop",
|
|
15
|
+
DISCOVERY_INCREMENTAL: "discovery_incremental",
|
|
16
|
+
DISCOVERY_SESSION_STATUS: "discovery_session_status",
|
|
17
|
+
EXECUTE_ACTION: "execute_action",
|
|
18
|
+
EXECUTE_RESULT: "execute_result",
|
|
19
|
+
READ_ENTITY: "read_entity",
|
|
20
|
+
READ_RESULT: "read_result",
|
|
21
|
+
MANIFEST_READY: "manifest_ready",
|
|
22
|
+
CHECKPOINT: "checkpoint",
|
|
23
|
+
CHECKPOINT_COMPLETE: "checkpoint_complete",
|
|
24
|
+
EXECUTE_WORKFLOW: "execute_workflow",
|
|
25
|
+
WORKFLOW_RESULT: "workflow_result"
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const createEnvelope = (type, payload = {}, requestId) => ({
|
|
29
|
+
type,
|
|
30
|
+
payload,
|
|
31
|
+
requestId
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const parseEnvelope = (raw) => {
|
|
35
|
+
if (typeof raw !== "string") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
|
|
42
|
+
if (!parsed || typeof parsed.type !== "string") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|