fauna-api 0.0.0-stage → 1.0.676

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.
Files changed (171) hide show
  1. package/dist/brands.d.ts +2 -0
  2. package/dist/brands.js +2 -0
  3. package/dist/index.d.ts +64 -0
  4. package/dist/index.js +121 -0
  5. package/dist/src/ant/chat.d.ts +130 -0
  6. package/dist/src/ant/chat.js +467 -0
  7. package/dist/src/ant/format.d.ts +40 -0
  8. package/dist/src/ant/format.js +143 -0
  9. package/dist/src/ant/graph.d.ts +130 -0
  10. package/dist/src/ant/graph.js +449 -0
  11. package/dist/src/ant/index.d.ts +16 -0
  12. package/dist/src/ant/index.js +61 -0
  13. package/dist/src/ant/persona.d.ts +151 -0
  14. package/dist/src/ant/persona.js +136 -0
  15. package/dist/src/ant/timeline.d.ts +283 -0
  16. package/dist/src/ant/timeline.js +1025 -0
  17. package/dist/src/authentification.d.ts +45 -0
  18. package/dist/src/authentification.js +194 -0
  19. package/dist/src/billing.d.ts +135 -0
  20. package/dist/src/billing.js +128 -0
  21. package/dist/src/calendar.d.ts +83 -0
  22. package/dist/src/calendar.js +133 -0
  23. package/dist/src/chats.d.ts +113 -0
  24. package/dist/src/chats.js +100 -0
  25. package/dist/src/cicada/contacts.d.ts +31 -0
  26. package/dist/src/cicada/contacts.js +28 -0
  27. package/dist/src/cicada/conversations.d.ts +73 -0
  28. package/dist/src/cicada/conversations.js +236 -0
  29. package/dist/src/cicada/inboxes.d.ts +48 -0
  30. package/dist/src/cicada/inboxes.js +82 -0
  31. package/dist/src/cicada/index.d.ts +16 -0
  32. package/dist/src/cicada/index.js +61 -0
  33. package/dist/src/cicada/phone.d.ts +48 -0
  34. package/dist/src/cicada/phone.js +306 -0
  35. package/dist/src/drive.d.ts +80 -0
  36. package/dist/src/drive.js +472 -0
  37. package/dist/src/extensions/index.d.ts +4 -0
  38. package/dist/src/extensions/index.js +5 -0
  39. package/dist/src/extensions/web/index.d.ts +6 -0
  40. package/dist/src/extensions/web/index.js +7 -0
  41. package/dist/src/extensions/web/session.d.ts +106 -0
  42. package/dist/src/extensions/web/session.js +469 -0
  43. package/dist/src/extensions/web/tools.d.ts +13 -0
  44. package/dist/src/extensions/web/tools.js +72 -0
  45. package/dist/src/friends.d.ts +39 -0
  46. package/dist/src/friends.js +119 -0
  47. package/dist/src/groups.d.ts +82 -0
  48. package/dist/src/groups.js +269 -0
  49. package/dist/src/inbox.d.ts +11 -0
  50. package/dist/src/inbox.js +35 -0
  51. package/dist/src/integrations.d.ts +52 -0
  52. package/dist/src/integrations.js +205 -0
  53. package/dist/src/magpie/explorer.d.ts +14 -0
  54. package/dist/src/magpie/explorer.js +36 -0
  55. package/dist/src/magpie/index.d.ts +16 -0
  56. package/dist/src/magpie/index.js +61 -0
  57. package/dist/src/magpie/portfolio.d.ts +52 -0
  58. package/dist/src/magpie/portfolio.js +179 -0
  59. package/dist/src/magpie/quotes.d.ts +35 -0
  60. package/dist/src/magpie/quotes.js +47 -0
  61. package/dist/src/magpie/room.d.ts +61 -0
  62. package/dist/src/magpie/room.js +238 -0
  63. package/dist/src/maps.d.ts +159 -0
  64. package/dist/src/maps.js +171 -0
  65. package/dist/src/marmot/browser.d.ts +139 -0
  66. package/dist/src/marmot/browser.js +389 -0
  67. package/dist/src/marmot/index.d.ts +20 -0
  68. package/dist/src/marmot/index.js +65 -0
  69. package/dist/src/marmot/media.d.ts +56 -0
  70. package/dist/src/marmot/media.js +265 -0
  71. package/dist/src/marmot/research.d.ts +337 -0
  72. package/dist/src/marmot/research.js +1138 -0
  73. package/dist/src/marmot/scraper.d.ts +59 -0
  74. package/dist/src/marmot/scraper.js +94 -0
  75. package/dist/src/marmot/web.d.ts +402 -0
  76. package/dist/src/marmot/web.js +2586 -0
  77. package/dist/src/marmot/workspace.d.ts +81 -0
  78. package/dist/src/marmot/workspace.js +263 -0
  79. package/dist/src/parrot/agent.d.ts +53 -0
  80. package/dist/src/parrot/agent.js +93 -0
  81. package/dist/src/parrot/chat.d.ts +84 -0
  82. package/dist/src/parrot/chat.js +237 -0
  83. package/dist/src/parrot/format.d.ts +128 -0
  84. package/dist/src/parrot/format.js +639 -0
  85. package/dist/src/parrot/generative.d.ts +261 -0
  86. package/dist/src/parrot/generative.js +185 -0
  87. package/dist/src/parrot/index.d.ts +22 -0
  88. package/dist/src/parrot/index.js +67 -0
  89. package/dist/src/parrot/room.d.ts +225 -0
  90. package/dist/src/parrot/room.js +466 -0
  91. package/dist/src/parrot/routing.d.ts +90 -0
  92. package/dist/src/parrot/routing.js +78 -0
  93. package/dist/src/parrot/share.d.ts +126 -0
  94. package/dist/src/parrot/share.js +261 -0
  95. package/dist/src/parrot/socket.d.ts +41 -0
  96. package/dist/src/parrot/socket.js +478 -0
  97. package/dist/src/payments.d.ts +73 -0
  98. package/dist/src/payments.js +107 -0
  99. package/dist/src/private/cards.css +75 -0
  100. package/dist/src/rooms.d.ts +76 -0
  101. package/dist/src/rooms.js +168 -0
  102. package/dist/src/shared/schema.d.ts +24 -0
  103. package/dist/src/shared/schema.js +1 -0
  104. package/dist/src/shared/server.d.ts +65 -0
  105. package/dist/src/shared/server.js +302 -0
  106. package/dist/src/termite/environment.d.ts +110 -0
  107. package/dist/src/termite/environment.js +263 -0
  108. package/dist/src/termite/index.d.ts +14 -0
  109. package/dist/src/termite/index.js +59 -0
  110. package/dist/src/termite/os.d.ts +326 -0
  111. package/dist/src/termite/os.js +572 -0
  112. package/dist/src/termite/versioning.d.ts +94 -0
  113. package/dist/src/termite/versioning.js +217 -0
  114. package/dist/src/tickets.d.ts +43 -0
  115. package/dist/src/tickets.js +138 -0
  116. package/dist/src/travel.d.ts +85 -0
  117. package/dist/src/travel.js +44 -0
  118. package/dist/src/ui/authentification.d.ts +24 -0
  119. package/dist/src/ui/authentification.js +177 -0
  120. package/dist/src/ui/brands.css +11 -0
  121. package/dist/src/ui/brands.d.ts +7 -0
  122. package/dist/src/ui/brands.js +14 -0
  123. package/dist/src/ui/browser.d.ts +74 -0
  124. package/dist/src/ui/browser.js +470 -0
  125. package/dist/src/ui/chat.d.ts +183 -0
  126. package/dist/src/ui/chat.js +2379 -0
  127. package/dist/src/ui/chatPanel.d.ts +109 -0
  128. package/dist/src/ui/chatPanel.js +768 -0
  129. package/dist/src/ui/chatSequence.d.ts +36 -0
  130. package/dist/src/ui/chatSequence.js +42 -0
  131. package/dist/src/ui/crown.d.ts +19 -0
  132. package/dist/src/ui/crown.js +199 -0
  133. package/dist/src/ui/cursor.d.ts +73 -0
  134. package/dist/src/ui/cursor.js +175 -0
  135. package/dist/src/ui/device.d.ts +33 -0
  136. package/dist/src/ui/device.js +49 -0
  137. package/dist/src/ui/drive.d.ts +69 -0
  138. package/dist/src/ui/drive.js +1243 -0
  139. package/dist/src/ui/generative.d.ts +39 -0
  140. package/dist/src/ui/generative.js +313 -0
  141. package/dist/src/ui/hover.d.ts +6 -0
  142. package/dist/src/ui/hover.js +210 -0
  143. package/dist/src/ui/index.d.ts +42 -0
  144. package/dist/src/ui/index.js +87 -0
  145. package/dist/src/ui/integrations.d.ts +13 -0
  146. package/dist/src/ui/integrations.js +175 -0
  147. package/dist/src/ui/maps.d.ts +87 -0
  148. package/dist/src/ui/maps.js +547 -0
  149. package/dist/src/ui/roster.d.ts +32 -0
  150. package/dist/src/ui/roster.js +163 -0
  151. package/dist/src/ui/talk.d.ts +12 -0
  152. package/dist/src/ui/talk.js +560 -0
  153. package/dist/src/ui/tickets.d.ts +13 -0
  154. package/dist/src/ui/tickets.js +966 -0
  155. package/dist/src/ui/toast.d.ts +7 -0
  156. package/dist/src/ui/toast.js +258 -0
  157. package/dist/src/ui/tokenText.d.ts +18 -0
  158. package/dist/src/ui/tokenText.js +96 -0
  159. package/dist/src/ui/tokenized.d.ts +42 -0
  160. package/dist/src/ui/tokenized.js +622 -0
  161. package/dist/src/ui/ui.css +4763 -0
  162. package/dist/src/ui/view.d.ts +23 -0
  163. package/dist/src/ui/view.js +584 -0
  164. package/dist/src/view.d.ts +113 -0
  165. package/dist/src/view.js +386 -0
  166. package/dist/src/webhooks.d.ts +66 -0
  167. package/dist/src/webhooks.js +78 -0
  168. package/dist/ui.d.ts +1 -0
  169. package/dist/ui.js +1 -0
  170. package/package.json +89 -6
  171. package/README.md +0 -3
@@ -0,0 +1,2586 @@
1
+ /**
2
+ * Marmot-owned live web-room client: DOM capture/replay, HTTP bootstrap/assets,
3
+ * and the WebRoom socket. Transport-agnostic capture plus the fauna share
4
+ * routes (`/extensions/web/live`, `/bootstrap`, `/asset`). Session orchestration
5
+ * (startWebShare, invite, chat pairing) stays in extensions/web.
6
+ */
7
+ import { Room, Presence, connect } from "../shared/server.js";
8
+ import { Marmot } from "./index.js";
9
+ // Attributes we never ship: they either leak host-local state or would break
10
+ // the viewer's isolated rendering (scripts, event handlers, live media).
11
+ const BLOCKED_ATTR_PREFIXES = ["on"];
12
+ const BLOCKED_TAGS = new Set(["SCRIPT", "NOSCRIPT"]);
13
+ /** Placeholder scheme rewritten into guest blob: URLs after asset download. */
14
+ export const WEB_ASSET_SCHEME = "fauna-web-asset:";
15
+ const MEDIA_SRC_TAGS = new Set(["IMG", "SOURCE", "VIDEO", "AUDIO", "TRACK", "EMBED", "INPUT"]);
16
+ const MEDIA_HREF_TAGS = new Set(["IMAGE", "USE"]); // SVG; not <a> / generic links
17
+ const EXTENSION_ROOT_ID = "fauna-extension-root";
18
+ let mediaFetcher = null;
19
+ /** Install a privileged media fetcher (e.g. chrome.runtime fetch-bytes). */
20
+ export function setMediaFetcher(fetcher) {
21
+ mediaFetcher = fetcher;
22
+ }
23
+ function isBlockedAttr(name) {
24
+ return BLOCKED_ATTR_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix));
25
+ }
26
+ function considerUrl(raw, into) {
27
+ if (!raw)
28
+ return;
29
+ const trimmed = raw.trim();
30
+ if (!trimmed || trimmed.startsWith(WEB_ASSET_SCHEME) || trimmed.startsWith("javascript:"))
31
+ return;
32
+ if (trimmed.startsWith("#") || trimmed.startsWith("mailto:") || trimmed.startsWith("tel:"))
33
+ return;
34
+ if (trimmed.startsWith("blob:"))
35
+ return;
36
+ // Leave data: URIs inline. Harvesting them is unnecessary (already self-
37
+ // contained) and a naive url() regex can truncate SVG data URIs at the
38
+ // first quote/paren, then rewrite the prefix and leave a base64 tail that
39
+ // the browser fetches as a relative path → HTTP 431.
40
+ if (trimmed.startsWith("data:"))
41
+ return;
42
+ into.add(trimmed);
43
+ }
44
+ /**
45
+ * Walk `url(...)` tokens in CSS. Quoted values may contain `)` / quotes that
46
+ * would break a naive `[^'")]+` character class (common in SVG data URIs).
47
+ */
48
+ function forEachCssUrlToken(css, visit) {
49
+ const re = /url\(\s*/gi;
50
+ let match;
51
+ while ((match = re.exec(css))) {
52
+ let i = match.index + match[0].length;
53
+ if (i >= css.length)
54
+ break;
55
+ const first = css[i];
56
+ if (first === '"' || first === "'") {
57
+ const quote = first;
58
+ const begin = i + 1;
59
+ const closeQuote = css.indexOf(quote, begin);
60
+ if (closeQuote < 0)
61
+ break;
62
+ const closeParen = css.indexOf(")", closeQuote + 1);
63
+ if (closeParen < 0)
64
+ break;
65
+ visit(css.slice(begin, closeQuote), quote, match.index, closeParen + 1);
66
+ re.lastIndex = closeParen + 1;
67
+ continue;
68
+ }
69
+ const closeParen = css.indexOf(")", i);
70
+ if (closeParen < 0)
71
+ break;
72
+ visit(css.slice(i, closeParen).trim(), "", match.index, closeParen + 1);
73
+ re.lastIndex = closeParen + 1;
74
+ }
75
+ }
76
+ /** Pull url(...) and @import targets out of CSS text (preserves @media as-is). */
77
+ export function extractCssUrls(css) {
78
+ const found = new Set();
79
+ forEachCssUrlToken(css, (raw) => considerUrl(raw, found));
80
+ const importRe = /@import\s+(?:url\(\s*(['"]?)([^'")]+)\1\s*\)|(['"])([^'"]+)\3)\s*;?/gi;
81
+ let match;
82
+ while ((match = importRe.exec(css))) {
83
+ considerUrl(match[2] ?? match[4] ?? null, found);
84
+ }
85
+ return [...found];
86
+ }
87
+ /** Rewrite url()/ @import targets in CSS using fauna-web-asset codes. */
88
+ export function rewriteCssUrls(css, map) {
89
+ let out = "";
90
+ let cursor = 0;
91
+ forEachCssUrlToken(css, (raw, quote, start, end) => {
92
+ const trimmed = raw.trim();
93
+ const code = map.get(trimmed);
94
+ out += css.slice(cursor, start);
95
+ if (!code)
96
+ out += css.slice(start, end);
97
+ else {
98
+ const q = quote || "";
99
+ out += `url(${q}${WEB_ASSET_SCHEME}${code}${q})`;
100
+ }
101
+ cursor = end;
102
+ });
103
+ out += css.slice(cursor);
104
+ out = out.replace(/@import\s+(?:url\(\s*(['"]?)([^'")]+)\1\s*\)|(['"])([^'"]+)\3)\s*;?/gi, (all, _q1, u1, _q2, u2) => {
105
+ const trimmed = (u1 || u2 || "").trim();
106
+ if (trimmed.startsWith("data:"))
107
+ return all;
108
+ const code = map.get(trimmed);
109
+ if (!code)
110
+ return all;
111
+ return `@import url("${WEB_ASSET_SCHEME}${code}");`;
112
+ });
113
+ return out;
114
+ }
115
+ /**
116
+ * Serialize readable document stylesheets as rule text (keeps @media / @supports).
117
+ * Opaque cross-origin sheets are skipped unless their <style> text is available.
118
+ */
119
+ export function serializeDocumentStyles(doc = document) {
120
+ const parts = [];
121
+ const urls = new Set();
122
+ for (const sheet of Array.from(doc.styleSheets)) {
123
+ let css = "";
124
+ try {
125
+ const rules = sheet.cssRules;
126
+ for (const rule of Array.from(rules))
127
+ css += `${rule.cssText}\n`;
128
+ }
129
+ catch {
130
+ const owner = sheet.ownerNode;
131
+ if (owner instanceof HTMLStyleElement)
132
+ css = owner.textContent || "";
133
+ else
134
+ continue;
135
+ }
136
+ if (!css.trim())
137
+ continue;
138
+ for (const u of extractCssUrls(css))
139
+ urls.add(u);
140
+ parts.push(css);
141
+ }
142
+ return { css: parts.join("\n"), urls: [...urls] };
143
+ }
144
+ function ownerDocOf(el) {
145
+ return el.ownerDocument || document;
146
+ }
147
+ // Framed documents are inlined into their host page. Their html, head, and body
148
+ // become divs so the viewer's HTML parser keeps the same tree the recorder walks,
149
+ // which keeps synthetic ids aligned across frames.
150
+ const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
151
+ const FRAME_SKIP_ATTRS = new Set(["src", "srcdoc", "sandbox", "allow", "data"]);
152
+ const FRAME_PLACEHOLDER_TEXT = "Embedded frame unavailable in share";
153
+ const FRAME_PLACEHOLDER_STYLE = "display:flex;align-items:center;justify-content:center;min-height:48px;"
154
+ + "background:#f3f3f3;color:#666;font:12px/1.4 system-ui,sans-serif;border:1px dashed #bbb;";
155
+ function frameDocument(el) {
156
+ try {
157
+ const nested = el.contentDocument;
158
+ return nested?.documentElement ? nested : null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /** `html`, `head`, or `body` when `el` is the root structure of a framed document. */
165
+ function frameRootTag(el) {
166
+ if (el.ownerDocument === document)
167
+ return null;
168
+ const tag = el.tagName.toLowerCase();
169
+ return tag === "html" || tag === "head" || tag === "body" ? tag : null;
170
+ }
171
+ /**
172
+ * Deep-serialize an element tree: strip scripts/extension UI, flatten open
173
+ * shadow roots into light DOM, replace opaque iframes with placeholders.
174
+ */
175
+ export function deepFreezeClone(source) {
176
+ const doc = ownerDocOf(source);
177
+ const cloneNodeDeep = (node) => {
178
+ if (node.nodeType === Node.TEXT_NODE) {
179
+ return doc.createTextNode(node.textContent ?? "");
180
+ }
181
+ if (node.nodeType === Node.COMMENT_NODE)
182
+ return null;
183
+ if (node.nodeType !== Node.ELEMENT_NODE)
184
+ return null;
185
+ const el = node;
186
+ if (el.id === EXTENSION_ROOT_ID || BLOCKED_TAGS.has(el.tagName))
187
+ return null;
188
+ if (FRAME_TAGS.has(el.tagName)) {
189
+ const wrap = doc.createElement("div");
190
+ for (const attr of Array.from(el.attributes)) {
191
+ if (isBlockedAttr(attr.name) || FRAME_SKIP_ATTRS.has(attr.name))
192
+ continue;
193
+ try {
194
+ wrap.setAttribute(attr.name, attr.value);
195
+ }
196
+ catch { /* skip */ }
197
+ }
198
+ const nested = frameDocument(el);
199
+ const frameClone = nested ? cloneNodeDeep(nested.documentElement) : null;
200
+ if (frameClone instanceof Element) {
201
+ wrap.setAttribute("data-fauna-iframe", "inlined");
202
+ wrap.appendChild(frameClone);
203
+ return wrap;
204
+ }
205
+ wrap.setAttribute("data-fauna-iframe", "blocked");
206
+ wrap.setAttribute("style", FRAME_PLACEHOLDER_STYLE);
207
+ wrap.appendChild(doc.createTextNode(FRAME_PLACEHOLDER_TEXT));
208
+ return wrap;
209
+ }
210
+ // Drop live stylesheet <link>s — document sheets are injected as frozen
211
+ // <style data-fauna-sheets>. Keep inline <style> (incl. open shadows).
212
+ if (el.tagName === "LINK") {
213
+ const rel = (el.getAttribute("rel") || "").toLowerCase();
214
+ if (rel.includes("stylesheet"))
215
+ return null;
216
+ }
217
+ const frameRoot = frameRootTag(el);
218
+ const clone = doc.createElement(frameRoot ? "div" : el.tagName.toLowerCase());
219
+ for (const attr of Array.from(el.attributes)) {
220
+ if (isBlockedAttr(attr.name))
221
+ continue;
222
+ try {
223
+ clone.setAttribute(attr.name, attr.value);
224
+ }
225
+ catch { /* skip */ }
226
+ }
227
+ if (frameRoot) {
228
+ clone.setAttribute("data-fauna-frame", frameRoot);
229
+ if (frameRoot === "head")
230
+ clone.setAttribute("hidden", "");
231
+ }
232
+ // A framed document's inputs aren't this window's HTMLInputElement, so go by tag.
233
+ if (el.tagName === "INPUT" && el.type === "password") {
234
+ clone.setAttribute("value", "");
235
+ }
236
+ else if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
237
+ try {
238
+ clone.setAttribute("value", el.value);
239
+ }
240
+ catch { /* skip */ }
241
+ }
242
+ for (const child of Array.from(el.childNodes)) {
243
+ const c = cloneNodeDeep(child);
244
+ if (c)
245
+ clone.appendChild(c);
246
+ }
247
+ const shadow = el.shadowRoot;
248
+ if (shadow) {
249
+ const wrap = doc.createElement("div");
250
+ wrap.setAttribute("data-fauna-shadow", "");
251
+ for (const child of Array.from(shadow.childNodes)) {
252
+ const c = cloneNodeDeep(child);
253
+ if (c)
254
+ wrap.appendChild(c);
255
+ }
256
+ clone.appendChild(wrap);
257
+ }
258
+ return clone;
259
+ };
260
+ const cloned = cloneNodeDeep(source);
261
+ if (!(cloned instanceof Element)) {
262
+ throw new Error("deepFreezeClone failed to clone root element");
263
+ }
264
+ return cloned;
265
+ }
266
+ /** True when this element carries a fetchable page asset (image/media), not a nav link. */
267
+ function isMediaElement(el) {
268
+ if (MEDIA_SRC_TAGS.has(el.tagName))
269
+ return true;
270
+ if (MEDIA_HREF_TAGS.has(el.tagName))
271
+ return true;
272
+ if (el.tagName === "LINK") {
273
+ const rel = (el.getAttribute("rel") || "").toLowerCase();
274
+ return rel.includes("icon") || rel.includes("preload") || rel.includes("apple-touch-icon");
275
+ }
276
+ return false;
277
+ }
278
+ /** Collect absolute/resolvable media URLs from a cleaned element tree. */
279
+ export function collectMediaUrls(root) {
280
+ const found = new Set();
281
+ const walk = (el) => {
282
+ if (isMediaElement(el)) {
283
+ if (el.hasAttribute("src"))
284
+ considerUrl(el.getAttribute("src"), found);
285
+ if (el.hasAttribute("poster"))
286
+ considerUrl(el.getAttribute("poster"), found);
287
+ if (el.hasAttribute("href"))
288
+ considerUrl(el.getAttribute("href"), found);
289
+ const srcset = el.getAttribute("srcset");
290
+ if (srcset) {
291
+ for (const part of srcset.split(",")) {
292
+ considerUrl(part.trim().split(/\s+/)[0] ?? null, found);
293
+ }
294
+ }
295
+ }
296
+ for (const child of Array.from(el.children))
297
+ walk(child);
298
+ };
299
+ walk(root);
300
+ return [...found];
301
+ }
302
+ /** Rewrite media URLs in-place using `map` (original URL → fauna-web-asset:code). */
303
+ export function rewriteMediaUrls(root, map) {
304
+ const rewrite = (raw) => {
305
+ if (!raw)
306
+ return raw;
307
+ const trimmed = raw.trim();
308
+ const code = map.get(trimmed);
309
+ return code ? `${WEB_ASSET_SCHEME}${code}` : raw;
310
+ };
311
+ const walk = (el) => {
312
+ if (el.tagName === "STYLE" && el.textContent) {
313
+ el.textContent = rewriteCssUrls(el.textContent, map);
314
+ }
315
+ if (isMediaElement(el)) {
316
+ for (const name of ["src", "poster", "href"]) {
317
+ if (!el.hasAttribute(name))
318
+ continue;
319
+ const next = rewrite(el.getAttribute(name));
320
+ if (next !== null)
321
+ el.setAttribute(name, next);
322
+ }
323
+ const srcset = el.getAttribute("srcset");
324
+ if (srcset) {
325
+ const parts = srcset.split(",").map((part) => {
326
+ const bits = part.trim().split(/\s+/);
327
+ const url = bits[0] ?? "";
328
+ const code = map.get(url);
329
+ if (!code)
330
+ return part.trim();
331
+ return [`${WEB_ASSET_SCHEME}${code}`, ...bits.slice(1)].join(" ");
332
+ });
333
+ el.setAttribute("srcset", parts.join(", "));
334
+ }
335
+ }
336
+ for (const child of Array.from(el.children))
337
+ walk(child);
338
+ };
339
+ walk(root);
340
+ }
341
+ export function extractAssetCodes(html) {
342
+ const codes = new Set();
343
+ const re = /fauna-web-asset:([0-9A-Za-z_-]+)/g;
344
+ let match;
345
+ while ((match = re.exec(html)))
346
+ codes.add(match[1]);
347
+ return [...codes];
348
+ }
349
+ /** Replace `fauna-web-asset:CODE` markers with blob/object URLs from `blobs`. */
350
+ export function materializeAssetUrls(html, blobs) {
351
+ return html.replace(/fauna-web-asset:([0-9A-Za-z_-]+)/g, (_all, code) => {
352
+ return blobs.get(code) ?? _all;
353
+ });
354
+ }
355
+ /**
356
+ * Deep-freeze `root` + inject serialized stylesheets. Does not rewrite assets —
357
+ * call `collectMediaUrls` / `rewriteMediaUrls` (and CSS urls) afterward.
358
+ */
359
+ export function toHtml(root = document.documentElement) {
360
+ return pageToHtml(root);
361
+ }
362
+ function decodeDataUrl(url) {
363
+ const comma = url.indexOf(",");
364
+ if (comma < 0)
365
+ return null;
366
+ const header = url.slice(5, comma);
367
+ const payload = url.slice(comma + 1);
368
+ const contentType = header.split(";")[0] || "application/octet-stream";
369
+ if (header.includes(";base64")) {
370
+ const bin = atob(payload);
371
+ const data = new Uint8Array(bin.length);
372
+ for (let i = 0; i < bin.length; i++)
373
+ data[i] = bin.charCodeAt(i);
374
+ return { data, contentType };
375
+ }
376
+ return { data: new TextEncoder().encode(decodeURIComponent(payload)), contentType };
377
+ }
378
+ /** Best-effort fetch of a media URL — prefers privileged `setMediaFetcher`. */
379
+ export async function fetchMediaBytes(url) {
380
+ try {
381
+ if (url.startsWith("data:"))
382
+ return decodeDataUrl(url);
383
+ if (mediaFetcher) {
384
+ const via = await mediaFetcher(url);
385
+ if (via)
386
+ return via;
387
+ }
388
+ const response = await fetch(url, { credentials: "include", mode: "cors" });
389
+ if (!response.ok)
390
+ return null;
391
+ const contentType = response.headers.get("Content-Type") || "application/octet-stream";
392
+ return { data: new Uint8Array(await response.arrayBuffer()), contentType };
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /** Absolute-resolve a URL against the current page (or a provided base). */
399
+ export function absoluteUrl(url, base) {
400
+ if (url.startsWith("data:") || url.startsWith("blob:") || url.startsWith(WEB_ASSET_SCHEME))
401
+ return url;
402
+ try {
403
+ return new URL(url, base
404
+ || (typeof location !== "undefined" ? location.href : "https://localhost/")).href;
405
+ }
406
+ catch {
407
+ return url;
408
+ }
409
+ }
410
+ /** Collect media + CSS urls from a frozen clone / stylesheet list. */
411
+ export function collectAllAssetUrls(root, cssUrls = []) {
412
+ const found = new Set(collectMediaUrls(root));
413
+ for (const u of cssUrls)
414
+ found.add(u);
415
+ for (const style of Array.from(root.querySelectorAll("style"))) {
416
+ for (const u of extractCssUrls(style.textContent || ""))
417
+ found.add(u);
418
+ }
419
+ return [...found];
420
+ }
421
+ /**
422
+ * Serialize a live DOM subtree into a flat `WebSnapshot`. The recorder tags each
423
+ * captured node with a synthetic id via a WeakMap so later mutations can
424
+ * reference nodes by that id without relying on DOM identity crossing the wire.
425
+ */
426
+ export class DomRecorder {
427
+ constructor(root = document.documentElement) {
428
+ Object.defineProperty(this, "root", {
429
+ enumerable: true,
430
+ configurable: true,
431
+ writable: true,
432
+ value: root
433
+ });
434
+ Object.defineProperty(this, "nextId", {
435
+ enumerable: true,
436
+ configurable: true,
437
+ writable: true,
438
+ value: 1
439
+ });
440
+ Object.defineProperty(this, "ids", {
441
+ enumerable: true,
442
+ configurable: true,
443
+ writable: true,
444
+ value: new WeakMap()
445
+ });
446
+ Object.defineProperty(this, "observer", {
447
+ enumerable: true,
448
+ configurable: true,
449
+ writable: true,
450
+ value: null
451
+ });
452
+ Object.defineProperty(this, "frames", {
453
+ enumerable: true,
454
+ configurable: true,
455
+ writable: true,
456
+ value: new Map()
457
+ });
458
+ Object.defineProperty(this, "onMutation", {
459
+ enumerable: true,
460
+ configurable: true,
461
+ writable: true,
462
+ value: null
463
+ });
464
+ Object.defineProperty(this, "detachResize", {
465
+ enumerable: true,
466
+ configurable: true,
467
+ writable: true,
468
+ value: null
469
+ });
470
+ }
471
+ /**
472
+ * Reset and DFS-assign synthetic ids on the live tree (same order as
473
+ * `serializeNode`, descending into framed documents). Call after publishing
474
+ * HTML bootstrap so mutations align with the guest's `applyHtml` bind.
475
+ */
476
+ bindIds() {
477
+ this.nextId = 1;
478
+ this.ids = new WeakMap();
479
+ const walk = (node) => {
480
+ if (node.nodeType === Node.TEXT_NODE) {
481
+ this.idFor(node);
482
+ return;
483
+ }
484
+ if (node.nodeType !== Node.ELEMENT_NODE)
485
+ return;
486
+ const el = node;
487
+ if (BLOCKED_TAGS.has(el.tagName))
488
+ return;
489
+ this.idFor(node);
490
+ if (FRAME_TAGS.has(el.tagName)) {
491
+ const entry = this.watchFrame(el);
492
+ if (entry.doc)
493
+ walk(entry.doc.documentElement);
494
+ else
495
+ entry.placeholder = this.nextId++;
496
+ return;
497
+ }
498
+ for (const child of Array.from(el.childNodes))
499
+ walk(child);
500
+ };
501
+ walk(this.root);
502
+ }
503
+ idFor(node) {
504
+ let id = this.ids.get(node);
505
+ if (id === undefined) {
506
+ id = this.nextId++;
507
+ this.ids.set(node, id);
508
+ }
509
+ return id;
510
+ }
511
+ serializeNode(node, into) {
512
+ if (node.nodeType === Node.TEXT_NODE) {
513
+ const id = this.idFor(node);
514
+ into[id] = { id, kind: "text", text: node.textContent ?? "" };
515
+ return id;
516
+ }
517
+ if (node.nodeType !== Node.ELEMENT_NODE)
518
+ return null;
519
+ const el = node;
520
+ if (BLOCKED_TAGS.has(el.tagName))
521
+ return null;
522
+ const id = this.idFor(node);
523
+ const attributes = {};
524
+ const frame = FRAME_TAGS.has(el.tagName);
525
+ for (const attr of Array.from(el.attributes)) {
526
+ if (isBlockedAttr(attr.name) || (frame && FRAME_SKIP_ATTRS.has(attr.name)))
527
+ continue;
528
+ attributes[attr.name] = attr.value;
529
+ }
530
+ const children = [];
531
+ if (frame) {
532
+ const entry = this.watchFrame(el);
533
+ children.push(this.serializeFrameContent(entry, into, attributes));
534
+ into[id] = { id, kind: "element", tag: "div", attributes, children };
535
+ return id;
536
+ }
537
+ for (const child of Array.from(el.childNodes)) {
538
+ const childId = this.serializeNode(child, into);
539
+ if (childId !== null)
540
+ children.push(childId);
541
+ }
542
+ const frameRoot = frameRootTag(el);
543
+ if (frameRoot) {
544
+ attributes["data-fauna-frame"] = frameRoot;
545
+ if (frameRoot === "head")
546
+ attributes.hidden = "";
547
+ }
548
+ into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
549
+ return id;
550
+ }
551
+ /** The one child a frame's wrapper holds: its document root, or a placeholder text. */
552
+ serializeFrameContent(entry, into, attributes) {
553
+ if (entry.doc) {
554
+ attributes["data-fauna-iframe"] = "inlined";
555
+ return this.serializeNode(entry.doc.documentElement, into);
556
+ }
557
+ attributes["data-fauna-iframe"] = "blocked";
558
+ attributes.style = FRAME_PLACEHOLDER_STYLE;
559
+ const textId = entry.placeholder ?? this.nextId++;
560
+ entry.placeholder = textId;
561
+ into[textId] = { id: textId, kind: "text", text: FRAME_PLACEHOLDER_TEXT };
562
+ return textId;
563
+ }
564
+ /** Track a frame: observe its document and re-inline it when it navigates. */
565
+ watchFrame(el) {
566
+ let entry = this.frames.get(el);
567
+ if (!entry) {
568
+ const created = { doc: null, placeholder: null, observer: null, onLoad: () => this.reinline(el) };
569
+ el.addEventListener("load", created.onLoad);
570
+ this.frames.set(el, created);
571
+ entry = created;
572
+ }
573
+ const doc = frameDocument(el);
574
+ if (doc !== entry.doc) {
575
+ entry.observer?.disconnect();
576
+ entry.observer = null;
577
+ entry.doc = doc;
578
+ }
579
+ if (doc && !entry.observer && this.onMutation && typeof MutationObserver !== "undefined") {
580
+ const onMutation = this.onMutation;
581
+ entry.observer = new MutationObserver((records) => this.handle(records, onMutation));
582
+ entry.observer.observe(doc, { subtree: true, childList: true, attributes: true, characterData: true });
583
+ }
584
+ return entry;
585
+ }
586
+ reinline(el) {
587
+ const parent = this.ids.get(el);
588
+ const entry = this.frames.get(el);
589
+ if (parent === undefined || !entry || !this.onMutation)
590
+ return;
591
+ const previous = entry.doc ? this.ids.get(entry.doc.documentElement) : entry.placeholder;
592
+ entry.placeholder = null;
593
+ this.watchFrame(el);
594
+ if (previous !== undefined && previous !== null)
595
+ this.onMutation({ kind: "remove", id: previous });
596
+ const map = {};
597
+ const childId = this.serializeFrameContent(entry, map, {});
598
+ const node = map[childId];
599
+ if (node)
600
+ this.onMutation({ kind: "insert", parent, node, subtree: Object.values(map), before: null });
601
+ }
602
+ /** Serialize a single node and its whole subtree into a flat node list (used
603
+ * to describe inserted subtrees in mutations). */
604
+ serializeSubtree(node) {
605
+ const map = {};
606
+ const rootId = this.serializeNode(node, map);
607
+ return { rootId, nodes: Object.values(map) };
608
+ }
609
+ /** Capture the current document as a snapshot. */
610
+ snapshot() {
611
+ const nodes = {};
612
+ const root = this.serializeNode(this.root, nodes);
613
+ return {
614
+ root: root ?? 0,
615
+ nodes,
616
+ viewport: {
617
+ width: typeof window !== "undefined" ? window.innerWidth : 0,
618
+ height: typeof window !== "undefined" ? window.innerHeight : 0,
619
+ },
620
+ };
621
+ }
622
+ handle(records, onMutation) {
623
+ for (const record of records) {
624
+ if (record.type === "characterData") {
625
+ const id = this.ids.get(record.target);
626
+ if (id !== undefined)
627
+ onMutation({ kind: "text", id, text: record.target.textContent ?? "" });
628
+ continue;
629
+ }
630
+ if (record.type === "attributes") {
631
+ const el = record.target;
632
+ const id = this.ids.get(el);
633
+ if (id === undefined || !record.attributeName)
634
+ continue;
635
+ if (isBlockedAttr(record.attributeName))
636
+ continue;
637
+ if (FRAME_TAGS.has(el.tagName) && FRAME_SKIP_ATTRS.has(record.attributeName))
638
+ continue;
639
+ onMutation({ kind: "attribute", id, name: record.attributeName, value: el.getAttribute(record.attributeName) });
640
+ continue;
641
+ }
642
+ if (record.type === "childList") {
643
+ for (const removed of Array.from(record.removedNodes)) {
644
+ const id = this.ids.get(removed);
645
+ if (id !== undefined)
646
+ onMutation({ kind: "remove", id });
647
+ }
648
+ const parentId = this.ids.get(record.target);
649
+ if (parentId === undefined)
650
+ continue;
651
+ for (const added of Array.from(record.addedNodes)) {
652
+ const { rootId, nodes } = this.serializeSubtree(added);
653
+ if (rootId === null)
654
+ continue;
655
+ const node = nodes.find((n) => n.id === rootId);
656
+ const beforeSibling = added.nextSibling;
657
+ const before = beforeSibling ? (this.ids.get(beforeSibling) ?? null) : null;
658
+ onMutation({ kind: "insert", parent: parentId, node, subtree: nodes, before });
659
+ }
660
+ }
661
+ }
662
+ }
663
+ /**
664
+ * Start observing the document, and every framed document it inlines.
665
+ * `onMutation` fires with a batched-flattened `WebMutation` for every DOM
666
+ * change. Also emits `viewport` mutations on resize. Call `stop()` to detach.
667
+ */
668
+ start(onMutation) {
669
+ if (typeof MutationObserver === "undefined")
670
+ return;
671
+ this.onMutation = onMutation;
672
+ this.observer = new MutationObserver((records) => this.handle(records, onMutation));
673
+ this.observer.observe(this.root, {
674
+ subtree: true,
675
+ childList: true,
676
+ attributes: true,
677
+ characterData: true,
678
+ });
679
+ for (const el of this.frames.keys())
680
+ this.watchFrame(el);
681
+ if (typeof window !== "undefined") {
682
+ const onResize = () => onMutation({ kind: "viewport", width: window.innerWidth, height: window.innerHeight });
683
+ window.addEventListener("resize", onResize);
684
+ this.detachResize = () => window.removeEventListener("resize", onResize);
685
+ }
686
+ }
687
+ stop() {
688
+ this.observer?.disconnect();
689
+ this.observer = null;
690
+ for (const [el, entry] of this.frames) {
691
+ entry.observer?.disconnect();
692
+ el.removeEventListener("load", entry.onLoad);
693
+ }
694
+ this.frames.clear();
695
+ this.onMutation = null;
696
+ this.detachResize?.();
697
+ this.detachResize = null;
698
+ }
699
+ }
700
+ /** An iframe's own defaults, for the divs inlined frames become. A framed document inherits
701
+ * nothing from the page around it, so its html starts from initial values. */
702
+ const FRAME_BOX_CSS = `:where([data-fauna-iframe]) { display: inline-block; width: 300px; height: 150px; border: 2px inset; overflow: hidden; vertical-align: baseline }
703
+ :where([data-fauna-iframe]) > :where([data-fauna-frame="html"]) { all: initial; display: block; width: 100%; height: 100%; overflow: auto }`;
704
+ const FRAME_ELEMENT_SELECTOR = /(^|[\s>+~,(])i?frame(?=$|[\s>+~,.#:[)])/gi;
705
+ const FRAME_ROOT_SELECTOR = /(^|[\s>+~,(])(?:html|:root)(?=$|[\s>+~,.#:[)])/g;
706
+ const FRAME_BODY_SELECTOR = /(^|[\s>+~,(])body(?=$|[\s>+~,.#:[)])/g;
707
+ /** A framed document's rule for inside `@scope` at its html div, with its root and body
708
+ * pointed at the divs standing in for them. Null for rules `@scope` can't hold
709
+ * (fonts, keyframes, imports), which stay where they are. */
710
+ function frameScopedRule(rule) {
711
+ const text = rule.cssText;
712
+ const open = text.indexOf("{");
713
+ if (open < 0)
714
+ return null;
715
+ const selector = rule.selectorText;
716
+ if (typeof selector === "string") {
717
+ const scoped = selector.replace(FRAME_ROOT_SELECTOR, "$1:scope").replace(FRAME_BODY_SELECTOR, '$1[data-fauna-frame="body"]');
718
+ return `${scoped} ${text.slice(open)}`;
719
+ }
720
+ const children = rule.cssRules;
721
+ if (!children || !/^@(media|supports|container|layer)\b/.test(text))
722
+ return null;
723
+ const inner = Array.from(children).map(frameScopedRule);
724
+ if (inner.some((t) => t === null))
725
+ return null;
726
+ return `${text.slice(0, open)}{ ${inner.join("\n")} }`;
727
+ }
728
+ /**
729
+ * Re-render a `WebSnapshot` into a target document (typically an isolated iframe
730
+ * or shadow root on the viewer) and apply incoming `WebMutation`s. The replayer
731
+ * keeps its own id->Node map mirroring the recorder's synthetic ids.
732
+ */
733
+ export class DomReplayer {
734
+ constructor(mount) {
735
+ Object.defineProperty(this, "mount", {
736
+ enumerable: true,
737
+ configurable: true,
738
+ writable: true,
739
+ value: mount
740
+ });
741
+ Object.defineProperty(this, "nodes", {
742
+ enumerable: true,
743
+ configurable: true,
744
+ writable: true,
745
+ value: new Map()
746
+ });
747
+ Object.defineProperty(this, "_viewport", {
748
+ enumerable: true,
749
+ configurable: true,
750
+ writable: true,
751
+ value: null
752
+ });
753
+ Object.defineProperty(this, "iframe", {
754
+ enumerable: true,
755
+ configurable: true,
756
+ writable: true,
757
+ value: null
758
+ });
759
+ // Mutations that arrive while a full document's frame loads. They follow its
760
+ // snapshot, so they apply once its ids are bound; a newer snapshot drops them.
761
+ Object.defineProperty(this, "queued", {
762
+ enumerable: true,
763
+ configurable: true,
764
+ writable: true,
765
+ value: null
766
+ });
767
+ Object.defineProperty(this, "loads", {
768
+ enumerable: true,
769
+ configurable: true,
770
+ writable: true,
771
+ value: 0
772
+ });
773
+ Object.defineProperty(this, "frameScopes", {
774
+ enumerable: true,
775
+ configurable: true,
776
+ writable: true,
777
+ value: 0
778
+ });
779
+ Object.defineProperty(this, "scopedSheets", {
780
+ enumerable: true,
781
+ configurable: true,
782
+ writable: true,
783
+ value: new WeakSet()
784
+ });
785
+ Object.defineProperty(this, "boxSheet", {
786
+ enumerable: true,
787
+ configurable: true,
788
+ writable: true,
789
+ value: null
790
+ });
791
+ Object.defineProperty(this, "boxes", {
792
+ enumerable: true,
793
+ configurable: true,
794
+ writable: true,
795
+ value: 0
796
+ });
797
+ }
798
+ /** Host viewport from the last snapshot / viewport mutation (advisory). */
799
+ getViewport() {
800
+ return this._viewport;
801
+ }
802
+ docRoot() {
803
+ if (this.iframe?.contentDocument?.documentElement) {
804
+ return this.iframe.contentDocument.documentElement;
805
+ }
806
+ return this.mount;
807
+ }
808
+ /**
809
+ * Apply a full cleaned HTML document (or fragment). Uses an iframe when the
810
+ * payload looks like a full document so `<html>/<head>/<body>` structure and
811
+ * DFS ids match the host.
812
+ */
813
+ async applyHtml(html, viewport) {
814
+ this.nodes.clear();
815
+ this._viewport = viewport ?? null;
816
+ const looksFull = /<html[\s>]/i.test(html);
817
+ const load = ++this.loads;
818
+ if (looksFull) {
819
+ this.queued = [];
820
+ this.mount.replaceChildren();
821
+ const iframe = this.mount.ownerDocument.createElement("iframe");
822
+ // Scripts/handlers are stripped on the host; keep the frame inert.
823
+ // No allow-scripts — shared pages must not execute host JS in the guest.
824
+ iframe.setAttribute("sandbox", "allow-same-origin allow-forms");
825
+ iframe.style.cssText = "border:0;width:100%;height:100%;background:#fff;display:block;";
826
+ this.mount.appendChild(iframe);
827
+ this.iframe = iframe;
828
+ // Defense in depth: drop scripts that survived capture so Chrome doesn't spam
829
+ // sandbox console errors (no allow-scripts by design). Script preloads stay as
830
+ // inert links: the host numbers them, so removing one would shift every id after.
831
+ const inert = html
832
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
833
+ .replace(/<script\b[^>]*\/>/gi, "")
834
+ .replace(/<link\b[^>]*rel=["'][^"']*preload[^"']*["'][^>]*>/gi, (tag) => /\bas=["'](script|module)["']/i.test(tag) || /rel=["']modulepreload["']/i.test(tag)
835
+ ? tag.replace(/rel=["'][^"']*["']/i, 'rel="fauna-inert"')
836
+ : tag);
837
+ try {
838
+ await new Promise((resolve, reject) => {
839
+ iframe.onload = () => resolve();
840
+ iframe.onerror = () => reject(new Error("Failed to load shared page frame."));
841
+ iframe.srcdoc = inert;
842
+ });
843
+ }
844
+ catch (err) {
845
+ if (load === this.loads)
846
+ this.queued = null;
847
+ throw err;
848
+ }
849
+ if (load !== this.loads)
850
+ return;
851
+ this.bindIds();
852
+ this.restyle();
853
+ // A textarea shows its text, not its value attribute.
854
+ for (const field of Array.from(iframe.contentDocument?.querySelectorAll("textarea[value]") ?? [])) {
855
+ field.value = field.getAttribute("value") ?? "";
856
+ }
857
+ const queued = this.queued ?? [];
858
+ this.queued = null;
859
+ for (const mutation of queued)
860
+ this.mutate(mutation);
861
+ return;
862
+ }
863
+ this.queued = null;
864
+ this.iframe = null;
865
+ this.mount.innerHTML = html;
866
+ this.bindIds();
867
+ }
868
+ /** Bind synthetic ids: from the ids a recorded snapshot carries, else in DFS order to
869
+ * match a recorder that numbers in that order. */
870
+ bindIds() {
871
+ this.nodes.clear();
872
+ const root = this.docRoot();
873
+ if (root.hasAttribute("data-fauna-node") || root.querySelector("[data-fauna-node]")) {
874
+ this.bindMarked(root);
875
+ return;
876
+ }
877
+ let nextId = 1;
878
+ const walk = (node) => {
879
+ if (node.nodeType === Node.TEXT_NODE) {
880
+ this.nodes.set(nextId++, node);
881
+ return;
882
+ }
883
+ if (node.nodeType !== Node.ELEMENT_NODE)
884
+ return;
885
+ const el = node;
886
+ // The snapshot's combined sheet isn't a node on the host.
887
+ if (BLOCKED_TAGS.has(el.tagName) || el.hasAttribute("data-fauna-sheets"))
888
+ return;
889
+ this.nodes.set(nextId++, el);
890
+ for (const child of Array.from(el.childNodes))
891
+ walk(child);
892
+ };
893
+ walk(this.docRoot());
894
+ }
895
+ /** Elements carry `data-fauna-node`, text-only elements name their text with
896
+ * `data-fauna-text`, and every other text node follows a `fauna:N` comment. A marker
897
+ * with no text after it was an empty text node on the host, so one is made here. */
898
+ bindMarked(root) {
899
+ const doc = root.ownerDocument;
900
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT);
901
+ for (let node = walker.currentNode; node; node = walker.nextNode()) {
902
+ if (node.nodeType === Node.ELEMENT_NODE) {
903
+ const el = node;
904
+ const id = el.getAttribute("data-fauna-node");
905
+ if (id)
906
+ this.nodes.set(Number(id), el);
907
+ const textId = el.getAttribute("data-fauna-text");
908
+ if (textId) {
909
+ const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE)
910
+ ?? el.appendChild(doc.createTextNode(""));
911
+ this.nodes.set(Number(textId), text);
912
+ }
913
+ continue;
914
+ }
915
+ const match = /^fauna:(\d+)$/.exec(node.nodeValue ?? "");
916
+ if (!match)
917
+ continue;
918
+ let text = node.nextSibling;
919
+ if (!text || text.nodeType !== Node.TEXT_NODE)
920
+ text = node.parentNode?.insertBefore(doc.createTextNode(""), node.nextSibling) ?? null;
921
+ if (text)
922
+ this.nodes.set(Number(match[1]), text);
923
+ }
924
+ }
925
+ /** The document the replay renders into: its iframe's for full documents, else the mount's. */
926
+ getDocument() {
927
+ return this.ownerDoc();
928
+ }
929
+ /** Scope inlined frames' styles, mirror hover and focus, and size frame boxes, for a full document's frame. */
930
+ restyle() {
931
+ this.scopeFrames();
932
+ this.mirrorStates();
933
+ this.frameBoxes();
934
+ }
935
+ /**
936
+ * Inlined frames are divs, so they get an iframe's box: inline block, 300 by 150 and
937
+ * clipped unless the page says otherwise, their `width`, `height` and `frameborder`
938
+ * attributes as sizes and border, and the framed document filling them. All at zero
939
+ * specificity in a sheet of its own, so page CSS wins, like a real iframe's
940
+ * presentational hints. No nodes join the replay, so ids stay aligned.
941
+ */
942
+ frameBoxes() {
943
+ const doc = this.iframe?.contentDocument;
944
+ const view = doc?.defaultView;
945
+ if (!doc || !view)
946
+ return;
947
+ if (!this.boxSheet || !doc.adoptedStyleSheets.includes(this.boxSheet)) {
948
+ this.boxSheet = new view.CSSStyleSheet();
949
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, this.boxSheet];
950
+ }
951
+ const size = (value) => {
952
+ const v = value?.trim() ?? "";
953
+ return /^\d+(\.\d+)?$/.test(v) ? `${v}px` : /^\d+(\.\d+)?(px|%)$/.test(v) ? v : "";
954
+ };
955
+ const rules = [FRAME_BOX_CSS];
956
+ for (const el of Array.from(doc.querySelectorAll("[data-fauna-iframe]"))) {
957
+ const width = size(el.getAttribute("width"));
958
+ const height = size(el.getAttribute("height"));
959
+ const borderless = el.getAttribute("frameborder") === "0";
960
+ if (!width && !height && !borderless)
961
+ continue;
962
+ let box = el.getAttribute("data-fauna-box");
963
+ if (!box) {
964
+ box = String(++this.boxes);
965
+ el.setAttribute("data-fauna-box", box);
966
+ }
967
+ const declarations = [width && `width: ${width}`, height && `height: ${height}`, borderless && "border: 0"].filter(Boolean);
968
+ rules.push(`:where([data-fauna-box="${box}"]) { ${declarations.join("; ")} }`);
969
+ }
970
+ this.boxSheet.replaceSync(rules.join("\n"));
971
+ }
972
+ /** Scroll a replayed element, or a framed document's html, to the host's `data-fauna-scroll`. */
973
+ applyScroll(el) {
974
+ const [x, y] = (el.getAttribute("data-fauna-scroll") ?? "").split(",").map((n) => Number(n) || 0);
975
+ el.scrollLeft = x ?? 0;
976
+ el.scrollTop = y ?? 0;
977
+ }
978
+ /**
979
+ * A framed document is inlined into the replay, so its stylesheets would style the
980
+ * whole copy: a site's `body { background }` repaints the page around its frame. Each
981
+ * of its sheets goes inside `@scope` at the frame's html div instead. Sheets from other
982
+ * origins can't be read, so they stay as they are.
983
+ */
984
+ scopeFrames() {
985
+ const doc = this.iframe?.contentDocument;
986
+ if (!doc)
987
+ return;
988
+ for (const sheet of Array.from(doc.styleSheets)) {
989
+ if (this.scopedSheets.has(sheet))
990
+ continue;
991
+ const root = sheet.ownerNode?.closest?.('[data-fauna-frame="html"]');
992
+ if (!root)
993
+ continue;
994
+ let rules;
995
+ try {
996
+ rules = sheet.cssRules;
997
+ }
998
+ catch {
999
+ continue;
1000
+ }
1001
+ this.scopedSheets.add(sheet);
1002
+ let scope = root.getAttribute("data-fauna-scope");
1003
+ if (!scope) {
1004
+ scope = String(++this.frameScopes);
1005
+ root.setAttribute("data-fauna-scope", scope);
1006
+ }
1007
+ for (let i = 0; i < rules.length; i++) {
1008
+ const original = rules[i].cssText;
1009
+ const inner = frameScopedRule(rules[i]);
1010
+ if (inner === null)
1011
+ continue;
1012
+ sheet.deleteRule(i);
1013
+ try {
1014
+ sheet.insertRule(`@scope ([data-fauna-scope="${scope}"]) { ${inner} }`, i);
1015
+ }
1016
+ catch {
1017
+ sheet.insertRule(original, i);
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ /**
1023
+ * `:hover` and `:focus` never apply in a replay. The host marks what they would match
1024
+ * with `data-fauna-hover` and the focus attributes, so point those selectors at the
1025
+ * attributes instead; both weigh the same in the cascade. Only a full document's own
1026
+ * frame is rewritten.
1027
+ */
1028
+ mirrorStates() {
1029
+ const doc = this.iframe?.contentDocument;
1030
+ if (!doc)
1031
+ return;
1032
+ const visit = (rules) => {
1033
+ for (const rule of Array.from(rules)) {
1034
+ const style = rule;
1035
+ if (typeof style.selectorText === "string" && /:(hover|focus)|frame/i.test(style.selectorText)) {
1036
+ // Inlined frames are divs, so rules for iframes point at their marker.
1037
+ style.selectorText = style.selectorText
1038
+ .replace(/:hover\b/g, "[data-fauna-hover]")
1039
+ .replace(/:focus-visible\b/g, "[data-fauna-focus-visible]")
1040
+ .replace(/:focus-within\b/g, "[data-fauna-focus-within]")
1041
+ .replace(/:focus(?![-\w])/g, "[data-fauna-focus]")
1042
+ .replace(FRAME_ELEMENT_SELECTOR, "$1[data-fauna-iframe]");
1043
+ }
1044
+ const nested = rule.cssRules;
1045
+ if (nested)
1046
+ visit(nested);
1047
+ }
1048
+ };
1049
+ for (const sheet of Array.from(doc.styleSheets)) {
1050
+ try {
1051
+ visit(sheet.cssRules);
1052
+ }
1053
+ catch { /* unreadable sheet */ }
1054
+ }
1055
+ }
1056
+ ownerDoc() {
1057
+ return this.iframe?.contentDocument ?? this.mount.ownerDocument;
1058
+ }
1059
+ build(id, map) {
1060
+ const spec = map[id];
1061
+ if (!spec)
1062
+ return null;
1063
+ if (spec.kind === "text") {
1064
+ const text = this.ownerDoc().createTextNode(spec.text);
1065
+ this.nodes.set(id, text);
1066
+ return text;
1067
+ }
1068
+ const el = this.ownerDoc().createElement(spec.tag);
1069
+ for (const [name, value] of Object.entries(spec.attributes)) {
1070
+ try {
1071
+ el.setAttribute(name, value);
1072
+ }
1073
+ catch { /* invalid attr name — skip */ }
1074
+ }
1075
+ this.nodes.set(id, el);
1076
+ for (const childId of spec.children) {
1077
+ const child = this.build(childId, map);
1078
+ if (child)
1079
+ el.appendChild(child);
1080
+ }
1081
+ return el;
1082
+ }
1083
+ buildFromList(node, list) {
1084
+ const map = {};
1085
+ for (const n of list)
1086
+ map[n.id] = n;
1087
+ return this.build(node.id, map);
1088
+ }
1089
+ /** Render a fresh snapshot, replacing whatever the mount currently holds. */
1090
+ apply(snapshot) {
1091
+ this.loads++;
1092
+ this.queued = null;
1093
+ this.nodes.clear();
1094
+ this.iframe = null;
1095
+ this.mount.innerHTML = "";
1096
+ this._viewport = snapshot.viewport ?? null;
1097
+ const root = this.build(snapshot.root, snapshot.nodes);
1098
+ if (root)
1099
+ this.mount.appendChild(root);
1100
+ }
1101
+ /** Apply one incremental mutation to the rendered tree. */
1102
+ mutate(mutation) {
1103
+ if (this.queued) {
1104
+ this.queued.push(mutation);
1105
+ return;
1106
+ }
1107
+ switch (mutation.kind) {
1108
+ case "text": {
1109
+ const node = this.nodes.get(mutation.id);
1110
+ if (node)
1111
+ node.textContent = mutation.text;
1112
+ if (node?.parentNode?.nodeName === "STYLE")
1113
+ this.restyle();
1114
+ return;
1115
+ }
1116
+ case "attribute": {
1117
+ // A full document replays in its own frame, whose nodes aren't this window's
1118
+ // Element, so check the node type rather than instanceof.
1119
+ const node = this.nodes.get(mutation.id);
1120
+ if (node?.nodeType !== Node.ELEMENT_NODE)
1121
+ return;
1122
+ const el = node;
1123
+ if (mutation.value === null)
1124
+ el.removeAttribute(mutation.name);
1125
+ else {
1126
+ try {
1127
+ el.setAttribute(mutation.name, mutation.value);
1128
+ }
1129
+ catch { /* skip */ }
1130
+ }
1131
+ // The attribute stops showing in a field once its text was set, and never shows in a textarea.
1132
+ if (mutation.name === "value" && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) {
1133
+ el.value = mutation.value ?? "";
1134
+ }
1135
+ if (mutation.name === "data-fauna-scroll")
1136
+ this.applyScroll(el);
1137
+ if ((mutation.name === "width" || mutation.name === "height" || mutation.name === "frameborder") && el.hasAttribute("data-fauna-iframe")) {
1138
+ this.frameBoxes();
1139
+ }
1140
+ return;
1141
+ }
1142
+ case "remove": {
1143
+ const node = this.nodes.get(mutation.id);
1144
+ if (node && node.parentNode)
1145
+ node.parentNode.removeChild(node);
1146
+ this.nodes.delete(mutation.id);
1147
+ return;
1148
+ }
1149
+ case "insert": {
1150
+ const parent = this.nodes.get(mutation.parent);
1151
+ if (!parent || (parent.nodeType !== Node.ELEMENT_NODE && parent.nodeType !== Node.DOCUMENT_FRAGMENT_NODE))
1152
+ return;
1153
+ const built = this.buildFromList(mutation.node, mutation.subtree);
1154
+ if (!built)
1155
+ return;
1156
+ const sibling = mutation.before !== null ? this.nodes.get(mutation.before) ?? null : null;
1157
+ parent.insertBefore(built, sibling?.parentNode === parent ? sibling : null);
1158
+ if (parent.nodeName === "STYLE" || mutation.subtree.some((n) => n.kind === "element" && n.tag === "style"))
1159
+ this.restyle();
1160
+ else if (mutation.subtree.some((n) => n.kind === "element" && "data-fauna-iframe" in n.attributes))
1161
+ this.frameBoxes();
1162
+ return;
1163
+ }
1164
+ case "viewport":
1165
+ this._viewport = { width: mutation.width, height: mutation.height };
1166
+ return;
1167
+ }
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Self-contained freeze + style serialize for `page.evaluate`. Closures over
1172
+ * module helpers break Puppeteer serialization, so helpers live inside.
1173
+ */
1174
+ export function pageToHtml(root = document.documentElement) {
1175
+ const BLOCKED_ATTR_PREFIXES = ["on"];
1176
+ const BLOCKED_TAGS = new Set(["SCRIPT", "NOSCRIPT"]);
1177
+ const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
1178
+ const FRAME_SKIP_ATTRS = new Set(["src", "srcdoc", "sandbox", "allow", "data"]);
1179
+ const EXTENSION_ROOT_ID = "fauna-extension-root";
1180
+ // URLs go out absolute, against their own document. Same rules as the recorder.
1181
+ const URL_ATTRS = new Set(["src", "href", "poster", "action", "data"]);
1182
+ const KEEP_URL = /^(data:|blob:|javascript:|#|mailto:|tel:|about:)/i;
1183
+ function isBlockedAttr(name) {
1184
+ return BLOCKED_ATTR_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix));
1185
+ }
1186
+ function absoluteUrl(raw, base) {
1187
+ const trimmed = raw.trim();
1188
+ if (!trimmed || KEEP_URL.test(trimmed))
1189
+ return raw;
1190
+ try {
1191
+ return new URL(trimmed, base).href;
1192
+ }
1193
+ catch {
1194
+ return raw;
1195
+ }
1196
+ }
1197
+ function absoluteCss(css, base) {
1198
+ return css.replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (all, quote, url) => {
1199
+ const next = absoluteUrl(url, base);
1200
+ return next === url ? all : `url(${quote}${next}${quote})`;
1201
+ });
1202
+ }
1203
+ function absoluteAttr(el, name, value) {
1204
+ if (URL_ATTRS.has(name))
1205
+ return absoluteUrl(value, el.baseURI);
1206
+ if (name === "srcset") {
1207
+ return value.split(",").map((part) => {
1208
+ const bits = part.trim().split(/\s+/);
1209
+ if (bits[0])
1210
+ bits[0] = absoluteUrl(bits[0], el.baseURI);
1211
+ return bits.join(" ");
1212
+ }).join(", ");
1213
+ }
1214
+ if (name === "style")
1215
+ return absoluteCss(value, el.baseURI);
1216
+ return value;
1217
+ }
1218
+ /** A linked stylesheet's rules, or "" while it loads or when it can't be read. */
1219
+ function sheetCss(link) {
1220
+ try {
1221
+ const sheet = link.sheet;
1222
+ if (!sheet)
1223
+ return "";
1224
+ let css = "";
1225
+ for (const rule of Array.from(sheet.cssRules))
1226
+ css += `${rule.cssText}\n`;
1227
+ return absoluteCss(css, sheet.href || link.href);
1228
+ }
1229
+ catch {
1230
+ return "";
1231
+ }
1232
+ }
1233
+ function ownerDocOf(el) {
1234
+ return el.ownerDocument || document;
1235
+ }
1236
+ function forEachCssUrlToken(css, visit) {
1237
+ const re = /url\(\s*/gi;
1238
+ let match;
1239
+ while ((match = re.exec(css))) {
1240
+ let i = match.index + match[0].length;
1241
+ if (i >= css.length)
1242
+ break;
1243
+ const first = css[i];
1244
+ if (first === '"' || first === "'") {
1245
+ const quote = first;
1246
+ const begin = i + 1;
1247
+ const closeQuote = css.indexOf(quote, begin);
1248
+ if (closeQuote < 0)
1249
+ break;
1250
+ const closeParen = css.indexOf(")", closeQuote + 1);
1251
+ if (closeParen < 0)
1252
+ break;
1253
+ visit(css.slice(begin, closeQuote), quote, match.index, closeParen + 1);
1254
+ re.lastIndex = closeParen + 1;
1255
+ continue;
1256
+ }
1257
+ const closeParen = css.indexOf(")", i);
1258
+ if (closeParen < 0)
1259
+ break;
1260
+ visit(css.slice(i, closeParen).trim(), "", match.index, closeParen + 1);
1261
+ re.lastIndex = closeParen + 1;
1262
+ }
1263
+ }
1264
+ function extractCssUrls(css) {
1265
+ const found = new Set();
1266
+ const consider = (raw) => {
1267
+ if (!raw)
1268
+ return;
1269
+ const trimmed = raw.trim();
1270
+ if (!trimmed || trimmed.startsWith("javascript:") || trimmed.startsWith("fauna-web-asset:"))
1271
+ return;
1272
+ if (trimmed.startsWith("#") || trimmed.startsWith("mailto:") || trimmed.startsWith("tel:"))
1273
+ return;
1274
+ if (trimmed.startsWith("blob:") || trimmed.startsWith("data:"))
1275
+ return;
1276
+ found.add(trimmed);
1277
+ };
1278
+ forEachCssUrlToken(css, (raw) => consider(raw));
1279
+ const importRe = /@import\s+(?:url\(\s*(['"]?)([^'")]+)\1\s*\)|(['"])([^'"]+)\3)\s*;?/gi;
1280
+ let match;
1281
+ while ((match = importRe.exec(css)))
1282
+ consider(match[2] ?? match[4] ?? null);
1283
+ return [...found];
1284
+ }
1285
+ function serializeDocumentStyles(doc) {
1286
+ const parts = [];
1287
+ const urls = new Set();
1288
+ for (const sheet of Array.from(doc.styleSheets)) {
1289
+ let css = "";
1290
+ try {
1291
+ const rules = sheet.cssRules;
1292
+ for (const rule of Array.from(rules))
1293
+ css += `${rule.cssText}\n`;
1294
+ }
1295
+ catch {
1296
+ const owner = sheet.ownerNode;
1297
+ if (owner instanceof HTMLStyleElement)
1298
+ css = owner.textContent || "";
1299
+ else
1300
+ continue;
1301
+ }
1302
+ if (!css.trim())
1303
+ continue;
1304
+ css = absoluteCss(css, sheet.href || doc.baseURI);
1305
+ for (const u of extractCssUrls(css))
1306
+ urls.add(u);
1307
+ parts.push(css);
1308
+ }
1309
+ return { css: parts.join("\n"), urls: [...urls] };
1310
+ }
1311
+ // With a recorder installed in this task, every node carries the id it numbered: elements
1312
+ // as data-fauna-node, text nodes as a <!--fauna:N--> comment just before them (which
1313
+ // also keeps neighbouring text apart and marks empty text), and the text of elements
1314
+ // whose content can't hold comments as data-fauna-text. The replay binds by these, so
1315
+ // it never depends on the HTML parsing back into the same nodes.
1316
+ const recorder = window.__faunaRecorder;
1317
+ const RAW_TEXT = new Set(["STYLE", "TITLE", "TEXTAREA", "XMP", "NOEMBED", "NOFRAMES"]);
1318
+ function deepFreezeClone(source) {
1319
+ const doc = ownerDocOf(source);
1320
+ const mark = (clone, node) => {
1321
+ const id = recorder?.id(node);
1322
+ if (id !== undefined)
1323
+ clone.setAttribute("data-fauna-node", String(id));
1324
+ };
1325
+ const markedText = (text, id) => {
1326
+ const node = doc.createTextNode(text);
1327
+ if (id === undefined)
1328
+ return node;
1329
+ const both = doc.createDocumentFragment();
1330
+ both.append(doc.createComment(`fauna:${id}`), node);
1331
+ return both;
1332
+ };
1333
+ const cloneNodeDeep = (node) => {
1334
+ if (node.nodeType === Node.TEXT_NODE) {
1335
+ const parent = node.parentNode;
1336
+ const raw = node.textContent ?? "";
1337
+ const text = parent?.nodeName === "STYLE" ? absoluteCss(raw, parent.baseURI) : raw;
1338
+ if (parent && RAW_TEXT.has(parent.nodeName))
1339
+ return doc.createTextNode(text);
1340
+ return markedText(text, recorder?.id(node));
1341
+ }
1342
+ if (node.nodeType === Node.COMMENT_NODE)
1343
+ return null;
1344
+ if (node.nodeType !== Node.ELEMENT_NODE)
1345
+ return null;
1346
+ const el = node;
1347
+ if (el.id === EXTENSION_ROOT_ID || BLOCKED_TAGS.has(el.tagName))
1348
+ return null;
1349
+ if (FRAME_TAGS.has(el.tagName)) {
1350
+ const wrap = doc.createElement("div");
1351
+ for (const attr of Array.from(el.attributes)) {
1352
+ if (isBlockedAttr(attr.name) || FRAME_SKIP_ATTRS.has(attr.name))
1353
+ continue;
1354
+ try {
1355
+ wrap.setAttribute(attr.name, attr.value);
1356
+ }
1357
+ catch { /* skip */ }
1358
+ }
1359
+ mark(wrap, el);
1360
+ let nested = null;
1361
+ try {
1362
+ nested = el.contentDocument;
1363
+ }
1364
+ catch {
1365
+ nested = null;
1366
+ }
1367
+ const frameClone = nested?.documentElement ? cloneNodeDeep(nested.documentElement) : null;
1368
+ if (frameClone instanceof Element) {
1369
+ wrap.setAttribute("data-fauna-iframe", "inlined");
1370
+ wrap.appendChild(frameClone);
1371
+ return wrap;
1372
+ }
1373
+ wrap.setAttribute("data-fauna-iframe", "blocked");
1374
+ wrap.setAttribute("style", "display:flex;align-items:center;justify-content:center;min-height:48px;background:#f3f3f3;color:#666;font:12px/1.4 system-ui,sans-serif;border:1px dashed #bbb;");
1375
+ wrap.appendChild(markedText("Embedded frame unavailable in share", recorder?.placeholder(el)));
1376
+ return wrap;
1377
+ }
1378
+ if (el.tagName === "LINK") {
1379
+ const rel = (el.getAttribute("rel") || "").toLowerCase();
1380
+ if (rel.includes("stylesheet")) {
1381
+ // The top document's sheets all go into one <style data-fauna-sheets> below,
1382
+ // which the replay doesn't number. The link stays as an inert placeholder,
1383
+ // so the replay numbers the same nodes as the recorder.
1384
+ if (el.ownerDocument === document) {
1385
+ const placeholder = doc.createElement("link");
1386
+ placeholder.setAttribute("rel", "fauna-inert");
1387
+ placeholder.setAttribute("data-fauna-link", "");
1388
+ mark(placeholder, el);
1389
+ return placeholder;
1390
+ }
1391
+ // A framed document's sheet stays in place with one text node, as the
1392
+ // recorder numbers it.
1393
+ const style = doc.createElement("style");
1394
+ style.setAttribute("data-fauna-sheet", el.href);
1395
+ mark(style, el);
1396
+ const sheetText = recorder?.sheetText(el);
1397
+ if (sheetText !== undefined)
1398
+ style.setAttribute("data-fauna-text", String(sheetText));
1399
+ style.appendChild(doc.createTextNode(sheetCss(el) || " "));
1400
+ return style;
1401
+ }
1402
+ }
1403
+ const rootTag = el.ownerDocument !== document ? el.tagName.toLowerCase() : "";
1404
+ const frameRoot = rootTag === "html" || rootTag === "head" || rootTag === "body" ? rootTag : "";
1405
+ const clone = doc.createElement(frameRoot ? "div" : el.tagName.toLowerCase());
1406
+ for (const attr of Array.from(el.attributes)) {
1407
+ if (isBlockedAttr(attr.name))
1408
+ continue;
1409
+ try {
1410
+ clone.setAttribute(attr.name, absoluteAttr(el, attr.name, attr.value));
1411
+ }
1412
+ catch { /* skip */ }
1413
+ }
1414
+ mark(clone, el);
1415
+ if (frameRoot) {
1416
+ clone.setAttribute("data-fauna-frame", frameRoot);
1417
+ if (frameRoot === "head")
1418
+ clone.setAttribute("hidden", "");
1419
+ }
1420
+ if (RAW_TEXT.has(el.nodeName)) {
1421
+ const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE);
1422
+ const textId = text ? recorder?.id(text) : undefined;
1423
+ if (textId !== undefined)
1424
+ clone.setAttribute("data-fauna-text", String(textId));
1425
+ }
1426
+ if (el.tagName === "INPUT" && el.type === "password")
1427
+ clone.setAttribute("value", "");
1428
+ else if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
1429
+ try {
1430
+ clone.setAttribute("value", el.value);
1431
+ }
1432
+ catch { /* skip */ }
1433
+ }
1434
+ for (const child of Array.from(el.childNodes)) {
1435
+ const c = cloneNodeDeep(child);
1436
+ if (c)
1437
+ clone.appendChild(c);
1438
+ }
1439
+ const shadow = el.shadowRoot;
1440
+ if (shadow) {
1441
+ const wrap = doc.createElement("div");
1442
+ wrap.setAttribute("data-fauna-shadow", "");
1443
+ mark(wrap, shadow);
1444
+ for (const child of Array.from(shadow.childNodes)) {
1445
+ const c = cloneNodeDeep(child);
1446
+ if (c)
1447
+ wrap.appendChild(c);
1448
+ }
1449
+ clone.appendChild(wrap);
1450
+ }
1451
+ return clone;
1452
+ };
1453
+ const cloned = cloneNodeDeep(source);
1454
+ if (!(cloned instanceof Element))
1455
+ throw new Error("deepFreezeClone failed to clone root element");
1456
+ return cloned;
1457
+ }
1458
+ if (root.nodeType !== Node.ELEMENT_NODE)
1459
+ throw new Error("toHtml requires an Element root");
1460
+ const source = root;
1461
+ const doc = ownerDocOf(source);
1462
+ const clone = deepFreezeClone(source);
1463
+ const frozen = serializeDocumentStyles(doc);
1464
+ const head = clone.tagName === "HTML"
1465
+ ? (clone.querySelector("head") || (() => {
1466
+ const h = doc.createElement("head");
1467
+ clone.insertBefore(h, clone.firstChild);
1468
+ return h;
1469
+ })())
1470
+ : null;
1471
+ if (frozen.css.trim()) {
1472
+ const style = doc.createElement("style");
1473
+ style.setAttribute("data-fauna-sheets", "");
1474
+ style.textContent = frozen.css;
1475
+ if (head)
1476
+ head.appendChild(style);
1477
+ else
1478
+ clone.insertBefore(style, clone.firstChild);
1479
+ }
1480
+ return {
1481
+ html: clone.outerHTML,
1482
+ viewport: {
1483
+ width: typeof window !== "undefined" ? window.innerWidth : 0,
1484
+ height: typeof window !== "undefined" ? window.innerHeight : 0,
1485
+ },
1486
+ clone,
1487
+ cssUrls: frozen.urls,
1488
+ };
1489
+ }
1490
+ /** Install a MutationObserver that posts WebMutations to `window[pushName]`.
1491
+ * Framed documents are walked, observed, and re-inlined on navigation, with
1492
+ * their html, head, and body addressed as the divs `pageToHtml` emits. */
1493
+ export function pageInstallRecorder(pushName) {
1494
+ const BLOCKED_ATTR_PREFIXES = ["on"];
1495
+ const BLOCKED_TAGS = new Set(["SCRIPT", "NOSCRIPT"]);
1496
+ const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
1497
+ const FRAME_SKIP_ATTRS = new Set(["src", "srcdoc", "sandbox", "allow", "data"]);
1498
+ const PLACEHOLDER_TEXT = "Embedded frame unavailable in share";
1499
+ const PLACEHOLDER_STYLE = "display:flex;align-items:center;justify-content:center;min-height:48px;background:#f3f3f3;color:#666;font:12px/1.4 system-ui,sans-serif;border:1px dashed #bbb;";
1500
+ const root = document.documentElement;
1501
+ const ids = new WeakMap();
1502
+ let nextId = 1;
1503
+ const frames = new Map();
1504
+ // `:hover` isn't in the DOM, so the chain it would match carries this attribute and
1505
+ // streams like any other; `DomReplayer` points the copy's `:hover` rules at it.
1506
+ const HOVER = "data-fauna-hover";
1507
+ let hovered = [];
1508
+ const hoverParent = (node) => {
1509
+ if (node.parentNode)
1510
+ return node.parentNode;
1511
+ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
1512
+ return node.host ?? null;
1513
+ if (node.nodeType === Node.DOCUMENT_NODE)
1514
+ return node.defaultView?.frameElement ?? null;
1515
+ return null;
1516
+ };
1517
+ const hoverChain = (target) => {
1518
+ const chain = [];
1519
+ for (let node = target; node; node = hoverParent(node)) {
1520
+ if (node.nodeType === Node.ELEMENT_NODE)
1521
+ chain.push(node);
1522
+ }
1523
+ return chain;
1524
+ };
1525
+ const setHover = (chain) => {
1526
+ const next = new Set(chain);
1527
+ const previous = new Set(hovered);
1528
+ for (const el of hovered)
1529
+ if (!next.has(el))
1530
+ el.removeAttribute(HOVER);
1531
+ for (const el of chain)
1532
+ if (!previous.has(el))
1533
+ el.setAttribute(HOVER, "");
1534
+ hovered = chain;
1535
+ };
1536
+ const onOver = (event) => setHover(hoverChain(event.target));
1537
+ const onOut = (event) => { if (!event.relatedTarget)
1538
+ setHover([]); };
1539
+ // Focus, the caret and a field's live text aren't in the DOM either. They go out as
1540
+ // attribute mutations the page itself never sees, so pages watching their own
1541
+ // attributes aren't disturbed: the focused element carries FOCUS (and FOCUS_VISIBLE
1542
+ // when it matches :focus-visible), its chain FOCUS_WITHIN, a text field CARET as
1543
+ // "start,end,scrollLeft,scrollTop", and `value` its current text.
1544
+ const FOCUS = "data-fauna-focus";
1545
+ const FOCUS_VISIBLE = "data-fauna-focus-visible";
1546
+ const FOCUS_WITHIN = "data-fauna-focus-within";
1547
+ const CARET = "data-fauna-caret";
1548
+ const FOCUS_EVENTS = ["focusin", "focusout", "selectionchange", "keyup", "mouseup", "scroll"];
1549
+ let focus = { chain: [], visible: false, caret: null };
1550
+ const values = new WeakMap();
1551
+ let focusQueued = false;
1552
+ let stopped = false;
1553
+ const pushAttr = (el, name, value) => {
1554
+ const id = ids.get(el);
1555
+ if (id !== undefined)
1556
+ push?.({ kind: "attribute", id, name, value });
1557
+ };
1558
+ const textField = (el) => {
1559
+ if (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA")
1560
+ return null;
1561
+ const field = el;
1562
+ return field.type === "password" ? null : field;
1563
+ };
1564
+ const pushValue = (el, force = false) => {
1565
+ const field = textField(el);
1566
+ if (!field || (!force && values.get(field) === field.value))
1567
+ return;
1568
+ values.set(field, field.value);
1569
+ pushAttr(field, "value", field.value);
1570
+ };
1571
+ const caretOf = (el) => {
1572
+ const field = textField(el);
1573
+ if (!field)
1574
+ return null;
1575
+ let start = null;
1576
+ let end = null;
1577
+ try {
1578
+ start = field.selectionStart;
1579
+ end = field.selectionEnd;
1580
+ }
1581
+ catch { /* a type without a selection */ }
1582
+ if (start === null || end === null)
1583
+ return null;
1584
+ return `${start},${end},${Math.round(field.scrollLeft)},${Math.round(field.scrollTop)}`;
1585
+ };
1586
+ const deepFocus = () => {
1587
+ let el = document.activeElement;
1588
+ while (el && FRAME_TAGS.has(el.tagName)) {
1589
+ let inner = null;
1590
+ try {
1591
+ inner = el.contentDocument?.activeElement ?? null;
1592
+ }
1593
+ catch {
1594
+ inner = null;
1595
+ }
1596
+ if (!inner || inner === inner.ownerDocument.body)
1597
+ break;
1598
+ el = inner;
1599
+ }
1600
+ return el && el !== el.ownerDocument.body && el !== el.ownerDocument.documentElement ? el : null;
1601
+ };
1602
+ const syncFocus = (force = false) => {
1603
+ const el = deepFocus();
1604
+ const chain = el ? hoverChain(el) : [];
1605
+ let visible = false;
1606
+ try {
1607
+ visible = !!el?.matches(":focus-visible");
1608
+ }
1609
+ catch { /* an engine without it */ }
1610
+ const caret = el ? caretOf(el) : null;
1611
+ const before = focus;
1612
+ const was = before.chain[0] ?? null;
1613
+ const next = new Set(chain);
1614
+ const previous = new Set(before.chain);
1615
+ for (const old of before.chain)
1616
+ if (!next.has(old))
1617
+ pushAttr(old, FOCUS_WITHIN, null);
1618
+ for (const now of chain)
1619
+ if (force || !previous.has(now))
1620
+ pushAttr(now, FOCUS_WITHIN, "");
1621
+ if (was && was !== el) {
1622
+ pushAttr(was, FOCUS, null);
1623
+ if (before.visible)
1624
+ pushAttr(was, FOCUS_VISIBLE, null);
1625
+ if (before.caret !== null)
1626
+ pushAttr(was, CARET, null);
1627
+ }
1628
+ if (el) {
1629
+ const moved = force || was !== el;
1630
+ if (moved)
1631
+ pushAttr(el, FOCUS, "");
1632
+ if (moved || visible !== before.visible)
1633
+ pushAttr(el, FOCUS_VISIBLE, visible ? "" : null);
1634
+ pushValue(el, force);
1635
+ if (moved || caret !== before.caret)
1636
+ pushAttr(el, CARET, caret);
1637
+ }
1638
+ focus = { chain, visible, caret };
1639
+ };
1640
+ const queueFocus = () => {
1641
+ if (focusQueued || stopped)
1642
+ return;
1643
+ focusQueued = true;
1644
+ setTimeout(() => {
1645
+ focusQueued = false;
1646
+ if (!stopped)
1647
+ syncFocus();
1648
+ }, 0);
1649
+ };
1650
+ const onInput = (event) => {
1651
+ if (event.target && event.target.nodeType === Node.ELEMENT_NODE)
1652
+ pushValue(event.target);
1653
+ queueFocus();
1654
+ };
1655
+ // Scroll positions aren't in the DOM either. A scrolled element carries SCROLL as "x,y",
1656
+ // and a framed document's scroll goes on its html; the copy scrolls to match. The top
1657
+ // document's own scroll travels with the pointer instead.
1658
+ const SCROLL = "data-fauna-scroll";
1659
+ const SCROLL_MS = 50;
1660
+ const scrolls = new WeakMap();
1661
+ let scrollQueue = null;
1662
+ const scrollTarget = (target) => {
1663
+ const node = target;
1664
+ if (!node)
1665
+ return null;
1666
+ if (node.nodeType === Node.DOCUMENT_NODE)
1667
+ return node === document ? null : node.documentElement;
1668
+ return node.nodeType === Node.ELEMENT_NODE ? node : null;
1669
+ };
1670
+ const scrollOf = (el) => {
1671
+ if (el === el.ownerDocument.documentElement) {
1672
+ const view = el.ownerDocument.defaultView;
1673
+ return `${Math.round(view?.scrollX ?? 0)},${Math.round(view?.scrollY ?? 0)}`;
1674
+ }
1675
+ return `${Math.round(el.scrollLeft)},${Math.round(el.scrollTop)}`;
1676
+ };
1677
+ const pushScroll = (el) => {
1678
+ const value = scrollOf(el);
1679
+ const at = value === "0,0" ? null : value;
1680
+ if ((scrolls.get(el) ?? null) === at)
1681
+ return;
1682
+ if (at)
1683
+ scrolls.set(el, at);
1684
+ else
1685
+ scrolls.delete(el);
1686
+ pushAttr(el, SCROLL, at);
1687
+ };
1688
+ // Scrolled before this install, so a fresh bootstrap shows them where they are.
1689
+ const scrolledAtInstall = [];
1690
+ const onScroll = (event) => {
1691
+ const el = scrollTarget(event.target);
1692
+ if (!el || stopped)
1693
+ return;
1694
+ if (!scrollQueue) {
1695
+ const queue = new Set();
1696
+ scrollQueue = queue;
1697
+ setTimeout(() => {
1698
+ scrollQueue = null;
1699
+ if (!stopped)
1700
+ for (const target of queue)
1701
+ pushScroll(target);
1702
+ }, SCROLL_MS);
1703
+ }
1704
+ scrollQueue.add(el);
1705
+ };
1706
+ const listenFocus = (doc) => {
1707
+ for (const name of FOCUS_EVENTS)
1708
+ doc.addEventListener(name, queueFocus, true);
1709
+ doc.addEventListener("input", onInput, true);
1710
+ doc.addEventListener("scroll", onScroll, true);
1711
+ };
1712
+ const unlistenFocus = (doc) => {
1713
+ for (const name of FOCUS_EVENTS)
1714
+ doc.removeEventListener(name, queueFocus, true);
1715
+ doc.removeEventListener("input", onInput, true);
1716
+ doc.removeEventListener("scroll", onScroll, true);
1717
+ };
1718
+ const push = window[pushName];
1719
+ const isBlockedAttr = (name) => BLOCKED_ATTR_PREFIXES.some((p) => name.toLowerCase().startsWith(p));
1720
+ const idFor = (node) => {
1721
+ let id = ids.get(node);
1722
+ if (id === undefined) {
1723
+ id = nextId++;
1724
+ ids.set(node, id);
1725
+ }
1726
+ return id;
1727
+ };
1728
+ const frameDocument = (el) => {
1729
+ try {
1730
+ const nested = el.contentDocument;
1731
+ return nested?.documentElement ? nested : null;
1732
+ }
1733
+ catch {
1734
+ return null;
1735
+ }
1736
+ };
1737
+ const frameRootTag = (el) => {
1738
+ if (el.ownerDocument === document)
1739
+ return "";
1740
+ const tag = el.tagName.toLowerCase();
1741
+ return tag === "html" || tag === "head" || tag === "body" ? tag : "";
1742
+ };
1743
+ // URLs resolve against their own document. The copy, and the host fetching assets
1744
+ // from the top page, would resolve a framed document's against something else, so
1745
+ // they go out absolute. Same rules as pageToHtml.
1746
+ const URL_ATTRS = new Set(["src", "href", "poster", "action", "data"]);
1747
+ const KEEP_URL = /^(data:|blob:|javascript:|#|mailto:|tel:|about:)/i;
1748
+ const absoluteUrl = (raw, base) => {
1749
+ const trimmed = raw.trim();
1750
+ if (!trimmed || KEEP_URL.test(trimmed))
1751
+ return raw;
1752
+ try {
1753
+ return new URL(trimmed, base).href;
1754
+ }
1755
+ catch {
1756
+ return raw;
1757
+ }
1758
+ };
1759
+ const absoluteCss = (css, base) => css.replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (all, quote, url) => {
1760
+ const next = absoluteUrl(url, base);
1761
+ return next === url ? all : `url(${quote}${next}${quote})`;
1762
+ });
1763
+ const absoluteAttr = (el, name, value) => {
1764
+ if (URL_ATTRS.has(name))
1765
+ return absoluteUrl(value, el.baseURI);
1766
+ if (name === "srcset") {
1767
+ return value.split(",").map((part) => {
1768
+ const bits = part.trim().split(/\s+/);
1769
+ if (bits[0])
1770
+ bits[0] = absoluteUrl(bits[0], el.baseURI);
1771
+ return bits.join(" ");
1772
+ }).join(", ");
1773
+ }
1774
+ if (name === "style")
1775
+ return absoluteCss(value, el.baseURI);
1776
+ return value;
1777
+ };
1778
+ const textOf = (node) => {
1779
+ const text = node.textContent ?? "";
1780
+ const parent = node.parentNode;
1781
+ return parent?.nodeName === "STYLE" ? absoluteCss(text, parent.baseURI) : text;
1782
+ };
1783
+ // A framed document's linked stylesheet goes out in place as a <style> with one text
1784
+ // node holding its rules, like pageToHtml, so both number the same nodes. Viewers fetch
1785
+ // nothing cross origin, and the copy can scope it. A sheet still loading sends its
1786
+ // rules when it loads.
1787
+ const isFrameSheet = (el) => el.tagName === "LINK" && el.ownerDocument !== document && /\bstylesheet\b/i.test(el.getAttribute("rel") ?? "");
1788
+ const sheetCss = (link) => {
1789
+ try {
1790
+ const sheet = link.sheet;
1791
+ if (!sheet)
1792
+ return "";
1793
+ let css = "";
1794
+ for (const rule of Array.from(sheet.cssRules))
1795
+ css += `${rule.cssText}\n`;
1796
+ return absoluteCss(css, sheet.href || link.href);
1797
+ }
1798
+ catch {
1799
+ return "";
1800
+ }
1801
+ };
1802
+ const sheetTexts = new Map();
1803
+ const sheetLoads = [];
1804
+ const sheetTextId = (link) => {
1805
+ let id = sheetTexts.get(link);
1806
+ if (id === undefined) {
1807
+ const textId = nextId++;
1808
+ id = textId;
1809
+ sheetTexts.set(link, textId);
1810
+ const onLoad = () => {
1811
+ if (!stopped)
1812
+ push?.({ kind: "text", id: textId, text: sheetCss(link) || " " });
1813
+ };
1814
+ link.addEventListener("load", onLoad);
1815
+ sheetLoads.push([link, onLoad]);
1816
+ }
1817
+ return id;
1818
+ };
1819
+ // Nodes serialized and nodes removed in the batch being handled. A node added inside a
1820
+ // subtree that was itself added in the same task is reported twice: once in that
1821
+ // subtree's insert, then on its own. Sending it again would show it twice.
1822
+ let sent = null;
1823
+ let gone = null;
1824
+ const handle = (records) => {
1825
+ sent = new Set();
1826
+ gone = new Set();
1827
+ try {
1828
+ handleRecords(records);
1829
+ // A focused node sent again arrives without its focus attributes.
1830
+ const resent = sent;
1831
+ if (focus.chain.some((el) => resent.has(el)))
1832
+ syncFocus(true);
1833
+ }
1834
+ finally {
1835
+ sent = null;
1836
+ gone = null;
1837
+ }
1838
+ };
1839
+ const handleRecords = (records) => {
1840
+ for (const record of records) {
1841
+ if (record.type === "characterData") {
1842
+ const id = ids.get(record.target);
1843
+ if (id !== undefined)
1844
+ push?.({ kind: "text", id, text: textOf(record.target) });
1845
+ continue;
1846
+ }
1847
+ if (record.type === "attributes") {
1848
+ const el = record.target;
1849
+ const id = ids.get(el);
1850
+ const name = record.attributeName;
1851
+ if (id === undefined || !name || isBlockedAttr(name))
1852
+ continue;
1853
+ if (FRAME_TAGS.has(el.tagName) && FRAME_SKIP_ATTRS.has(name))
1854
+ continue;
1855
+ const value = el.getAttribute(name);
1856
+ push?.({ kind: "attribute", id, name, value: value === null ? null : absoluteAttr(el, name, value) });
1857
+ continue;
1858
+ }
1859
+ if (record.type === "childList") {
1860
+ for (const removed of Array.from(record.removedNodes)) {
1861
+ gone?.add(removed);
1862
+ const id = ids.get(removed);
1863
+ if (id !== undefined)
1864
+ push?.({ kind: "remove", id });
1865
+ }
1866
+ const parentId = ids.get(record.target);
1867
+ if (parentId === undefined)
1868
+ continue;
1869
+ for (const added of Array.from(record.addedNodes)) {
1870
+ if (sent?.has(added) && !gone?.has(added))
1871
+ continue;
1872
+ const map = {};
1873
+ const rootId = serializeNode(added, map);
1874
+ if (rootId === null)
1875
+ continue;
1876
+ const node = map[rootId];
1877
+ const beforeSibling = added.nextSibling;
1878
+ const before = beforeSibling ? (ids.get(beforeSibling) ?? null) : null;
1879
+ push?.({ kind: "insert", parent: parentId, node, subtree: Object.values(map), before });
1880
+ }
1881
+ }
1882
+ }
1883
+ };
1884
+ // An open shadow root goes out as a <div data-fauna-shadow> after its host's own
1885
+ // children, like pageToHtml, and is watched like the rest of the page.
1886
+ const shadowObserver = new MutationObserver((records) => handle(records));
1887
+ const watched = new WeakSet();
1888
+ const shadowOf = (el) => el.shadowRoot ?? null;
1889
+ const watchShadow = (shadow) => {
1890
+ if (watched.has(shadow))
1891
+ return;
1892
+ watched.add(shadow);
1893
+ shadowObserver.observe(shadow, { subtree: true, childList: true, attributes: true, characterData: true });
1894
+ };
1895
+ const serializeShadow = (shadow, into) => {
1896
+ const id = idFor(shadow);
1897
+ sent?.add(shadow);
1898
+ watchShadow(shadow);
1899
+ const children = [];
1900
+ for (const child of Array.from(shadow.childNodes)) {
1901
+ const childId = serializeNode(child, into);
1902
+ if (childId !== null)
1903
+ children.push(childId);
1904
+ }
1905
+ into[id] = { id, kind: "element", tag: "div", attributes: { "data-fauna-shadow": "" }, children };
1906
+ return id;
1907
+ };
1908
+ const watchFrame = (el) => {
1909
+ let frame = frames.get(el);
1910
+ if (!frame) {
1911
+ const created = { doc: null, placeholder: null, observer: null, onLoad: () => reinline(el) };
1912
+ el.addEventListener("load", created.onLoad);
1913
+ frames.set(el, created);
1914
+ frame = created;
1915
+ }
1916
+ const doc = frameDocument(el);
1917
+ if (doc !== frame.doc) {
1918
+ frame.observer?.disconnect();
1919
+ frame.observer = null;
1920
+ if (frame.doc) {
1921
+ frame.doc.removeEventListener("mouseover", onOver, true);
1922
+ unlistenFocus(frame.doc);
1923
+ }
1924
+ frame.doc = doc;
1925
+ }
1926
+ if (doc && !frame.observer) {
1927
+ frame.observer = new MutationObserver(handle);
1928
+ frame.observer.observe(doc, { subtree: true, childList: true, attributes: true, characterData: true });
1929
+ doc.addEventListener("mouseover", onOver, true);
1930
+ listenFocus(doc);
1931
+ }
1932
+ return frame;
1933
+ };
1934
+ const serializeFrameContent = (frame, into, attributes) => {
1935
+ if (frame.doc) {
1936
+ attributes["data-fauna-iframe"] = "inlined";
1937
+ return serializeNode(frame.doc.documentElement, into);
1938
+ }
1939
+ attributes["data-fauna-iframe"] = "blocked";
1940
+ attributes.style = PLACEHOLDER_STYLE;
1941
+ const textId = frame.placeholder ?? nextId++;
1942
+ frame.placeholder = textId;
1943
+ into[textId] = { id: textId, kind: "text", text: PLACEHOLDER_TEXT };
1944
+ return textId;
1945
+ };
1946
+ const serializeNode = (node, into) => {
1947
+ if (node.nodeType === Node.TEXT_NODE) {
1948
+ const id = idFor(node);
1949
+ sent?.add(node);
1950
+ into[id] = { id, kind: "text", text: textOf(node) };
1951
+ return id;
1952
+ }
1953
+ if (node.nodeType !== Node.ELEMENT_NODE)
1954
+ return null;
1955
+ const el = node;
1956
+ if (BLOCKED_TAGS.has(el.tagName))
1957
+ return null;
1958
+ const id = idFor(node);
1959
+ sent?.add(node);
1960
+ if (isFrameSheet(el)) {
1961
+ const textId = sheetTextId(el);
1962
+ into[textId] = { id: textId, kind: "text", text: sheetCss(el) || " " };
1963
+ into[id] = { id, kind: "element", tag: "style", attributes: { "data-fauna-sheet": el.href }, children: [textId] };
1964
+ return id;
1965
+ }
1966
+ const isFrame = FRAME_TAGS.has(el.tagName);
1967
+ const attributes = {};
1968
+ for (const attr of Array.from(el.attributes)) {
1969
+ if (isBlockedAttr(attr.name) || (isFrame && FRAME_SKIP_ATTRS.has(attr.name)))
1970
+ continue;
1971
+ attributes[attr.name] = absoluteAttr(el, attr.name, attr.value);
1972
+ }
1973
+ if (isFrame) {
1974
+ const children = [serializeFrameContent(watchFrame(el), into, attributes)];
1975
+ into[id] = { id, kind: "element", tag: "div", attributes, children };
1976
+ return id;
1977
+ }
1978
+ const children = [];
1979
+ for (const child of Array.from(el.childNodes)) {
1980
+ const childId = serializeNode(child, into);
1981
+ if (childId !== null)
1982
+ children.push(childId);
1983
+ }
1984
+ const shadow = shadowOf(el);
1985
+ if (shadow)
1986
+ children.push(serializeShadow(shadow, into));
1987
+ const frameRoot = frameRootTag(el);
1988
+ if (frameRoot) {
1989
+ attributes["data-fauna-frame"] = frameRoot;
1990
+ if (frameRoot === "head")
1991
+ attributes.hidden = "";
1992
+ }
1993
+ into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
1994
+ return id;
1995
+ };
1996
+ const reinline = (el) => {
1997
+ const parent = ids.get(el);
1998
+ const frame = frames.get(el);
1999
+ if (parent === undefined || !frame)
2000
+ return;
2001
+ const previous = frame.doc ? ids.get(frame.doc.documentElement) : frame.placeholder;
2002
+ frame.placeholder = null;
2003
+ watchFrame(el);
2004
+ if (previous !== undefined && previous !== null)
2005
+ push?.({ kind: "remove", id: previous });
2006
+ const map = {};
2007
+ const wrapper = {};
2008
+ const childId = serializeFrameContent(frame, map, wrapper);
2009
+ // A frame that was blocked and now loads drops its placeholder look, and back.
2010
+ push?.({ kind: "attribute", id: parent, name: "data-fauna-iframe", value: wrapper["data-fauna-iframe"] ?? null });
2011
+ const style = el.getAttribute("style");
2012
+ push?.({ kind: "attribute", id: parent, name: "style", value: wrapper.style ?? (style === null ? null : absoluteAttr(el, "style", style)) });
2013
+ const node = map[childId];
2014
+ if (node)
2015
+ push?.({ kind: "insert", parent, node, subtree: Object.values(map), before: null });
2016
+ };
2017
+ const walkBind = (node) => {
2018
+ if (node.nodeType === Node.TEXT_NODE) {
2019
+ idFor(node);
2020
+ return;
2021
+ }
2022
+ if (node.nodeType !== Node.ELEMENT_NODE)
2023
+ return;
2024
+ const el = node;
2025
+ if (BLOCKED_TAGS.has(el.tagName))
2026
+ return;
2027
+ idFor(node);
2028
+ // A recorder reinstalled for a fresh bootstrap picks up the hover its predecessor left.
2029
+ if (el.hasAttribute(HOVER))
2030
+ hovered.push(el);
2031
+ if (isFrameSheet(el)) {
2032
+ sheetTextId(el);
2033
+ return;
2034
+ }
2035
+ if (FRAME_TAGS.has(el.tagName)) {
2036
+ const frame = watchFrame(el);
2037
+ if (frame.doc) {
2038
+ walkBind(frame.doc.documentElement);
2039
+ const view = frame.doc.defaultView;
2040
+ if (view?.scrollX || view?.scrollY)
2041
+ scrolledAtInstall.push(frame.doc.documentElement);
2042
+ }
2043
+ else
2044
+ frame.placeholder = nextId++;
2045
+ return;
2046
+ }
2047
+ if (el !== document.documentElement && (el.scrollTop || el.scrollLeft))
2048
+ scrolledAtInstall.push(el);
2049
+ for (const child of Array.from(el.childNodes))
2050
+ walkBind(child);
2051
+ const shadow = shadowOf(el);
2052
+ if (shadow) {
2053
+ idFor(shadow);
2054
+ watchShadow(shadow);
2055
+ for (const child of Array.from(shadow.childNodes))
2056
+ walkBind(child);
2057
+ }
2058
+ };
2059
+ const prev = window.__faunaStopRecorder;
2060
+ prev?.();
2061
+ walkBind(root);
2062
+ window.__faunaRecorder = {
2063
+ id: (node) => ids.get(node),
2064
+ sheetText: (link) => sheetTexts.get(link),
2065
+ placeholder: (el) => frames.get(el)?.placeholder ?? undefined,
2066
+ };
2067
+ const observer = new MutationObserver(handle);
2068
+ observer.observe(root, { subtree: true, childList: true, attributes: true, characterData: true });
2069
+ const onResize = () => push?.({ kind: "viewport", width: window.innerWidth, height: window.innerHeight });
2070
+ window.addEventListener("resize", onResize);
2071
+ document.addEventListener("mouseover", onOver, true);
2072
+ document.addEventListener("mouseout", onOut, true);
2073
+ listenFocus(document);
2074
+ // Everything pushed after this belongs to the bootstrap serialized alongside this
2075
+ // install. The host holds it until that bootstrap has gone out.
2076
+ push?.({ kind: "epoch" });
2077
+ syncFocus(true);
2078
+ for (const el of scrolledAtInstall)
2079
+ pushScroll(el);
2080
+ window.__faunaStopRecorder = () => {
2081
+ stopped = true;
2082
+ delete window.__faunaRecorder;
2083
+ observer.disconnect();
2084
+ shadowObserver.disconnect();
2085
+ for (const [link, onLoad] of sheetLoads)
2086
+ link.removeEventListener("load", onLoad);
2087
+ for (const [el, frame] of frames) {
2088
+ frame.observer?.disconnect();
2089
+ if (frame.doc) {
2090
+ frame.doc.removeEventListener("mouseover", onOver, true);
2091
+ unlistenFocus(frame.doc);
2092
+ }
2093
+ el.removeEventListener("load", frame.onLoad);
2094
+ }
2095
+ frames.clear();
2096
+ window.removeEventListener("resize", onResize);
2097
+ document.removeEventListener("mouseover", onOver, true);
2098
+ document.removeEventListener("mouseout", onOut, true);
2099
+ unlistenFocus(document);
2100
+ };
2101
+ }
2102
+ export function pageStopRecorder() {
2103
+ const stop = window.__faunaStopRecorder;
2104
+ stop?.();
2105
+ }
2106
+ /** Runs in the page: the pointer at (x, y) with the CSS cursor of whatever is under it,
2107
+ * looking inside frames the recorder inlines. */
2108
+ export function pageReadPointer(x = 0, y = 0, visible = true) {
2109
+ let shape = "default";
2110
+ if (typeof document !== "undefined") {
2111
+ let px = x;
2112
+ let py = y;
2113
+ let el = document.elementFromPoint(px, py);
2114
+ while (el && (el.tagName === "IFRAME" || el.tagName === "FRAME")) {
2115
+ let inner = null;
2116
+ try {
2117
+ inner = el.contentDocument;
2118
+ }
2119
+ catch {
2120
+ inner = null;
2121
+ }
2122
+ if (!inner)
2123
+ break;
2124
+ const rect = el.getBoundingClientRect();
2125
+ px -= rect.left;
2126
+ py -= rect.top;
2127
+ el = inner.elementFromPoint(px, py);
2128
+ }
2129
+ if (el) {
2130
+ const cursor = ((el.ownerDocument.defaultView ?? window).getComputedStyle(el).cursor || "").split(",").pop().trim();
2131
+ if (cursor && cursor !== "auto")
2132
+ shape = cursor;
2133
+ }
2134
+ }
2135
+ return {
2136
+ x,
2137
+ y,
2138
+ scrollX: typeof window !== "undefined" ? window.scrollX : 0,
2139
+ scrollY: typeof window !== "undefined" ? window.scrollY : 0,
2140
+ visible,
2141
+ shape,
2142
+ };
2143
+ }
2144
+ /** In-page media fetch for Puppeteer evaluate (cookies, no chrome.runtime). */
2145
+ export async function pageFetchMedia(url) {
2146
+ try {
2147
+ if (url.startsWith("data:")) {
2148
+ const comma = url.indexOf(",");
2149
+ if (comma < 0)
2150
+ return null;
2151
+ const header = url.slice(5, comma);
2152
+ const payload = url.slice(comma + 1);
2153
+ const contentType = header.split(";")[0] || "application/octet-stream";
2154
+ if (header.includes(";base64"))
2155
+ return { dataBase64: payload, contentType };
2156
+ const raw = decodeURIComponent(payload);
2157
+ return { dataBase64: btoa(raw), contentType };
2158
+ }
2159
+ const response = await fetch(url, { credentials: "include", mode: "cors" });
2160
+ if (!response.ok)
2161
+ return null;
2162
+ const buf = new Uint8Array(await response.arrayBuffer());
2163
+ let binary = "";
2164
+ for (let i = 0; i < buf.length; i++)
2165
+ binary += String.fromCharCode(buf[i]);
2166
+ return {
2167
+ dataBase64: btoa(binary),
2168
+ contentType: response.headers.get("Content-Type") || "application/octet-stream",
2169
+ };
2170
+ }
2171
+ catch {
2172
+ return null;
2173
+ }
2174
+ }
2175
+ /**
2176
+ * Upload cleaned HTML as a raw stream (XHR for upload progress, Drive-style).
2177
+ */
2178
+ export function uploadBootstrap(authorization, target, html, options = {}) {
2179
+ const body = typeof html === "string" ? new TextEncoder().encode(html) : html;
2180
+ const version = options.version ?? 1;
2181
+ return new Promise((resolve, reject) => {
2182
+ const xhr = new XMLHttpRequest();
2183
+ xhr.open("POST", `${Marmot.getURL()}/extensions/web/bootstrap`);
2184
+ xhr.setRequestHeader("Authorization", "email" in authorization ? `Token ${authorization.token}` : `ApiKey ${authorization.id}`);
2185
+ if ("code" in target)
2186
+ xhr.setRequestHeader("Access-Code", target.code);
2187
+ else
2188
+ xhr.setRequestHeader("Server", target.reference);
2189
+ xhr.setRequestHeader("Content-Type", options.contentType ?? "text/html; charset=utf-8");
2190
+ xhr.setRequestHeader("Bootstrap-Version", String(version));
2191
+ if (options.viewport) {
2192
+ xhr.setRequestHeader("Viewport-Width", String(options.viewport.width));
2193
+ xhr.setRequestHeader("Viewport-Height", String(options.viewport.height));
2194
+ }
2195
+ xhr.upload.onprogress = (event) => {
2196
+ if (event.lengthComputable && event.total > 0 && options.onProgress) {
2197
+ options.onProgress(Math.min(Math.max(event.loaded / event.total, 0), 1));
2198
+ }
2199
+ };
2200
+ xhr.onload = () => {
2201
+ options.onProgress?.(1);
2202
+ if (xhr.status >= 200 && xhr.status < 300) {
2203
+ try {
2204
+ resolve(JSON.parse(xhr.responseText));
2205
+ }
2206
+ catch {
2207
+ resolve({ ok: true, version });
2208
+ }
2209
+ }
2210
+ else {
2211
+ reject(new Error(xhr.responseText || `${xhr.status}: bootstrap upload failed`));
2212
+ }
2213
+ };
2214
+ xhr.onerror = () => reject(new Error("Failed to upload bootstrap."));
2215
+ xhr.send(new Uint8Array(body));
2216
+ });
2217
+ }
2218
+ /**
2219
+ * Download the room HTML bootstrap with download progress when Content-Length is known.
2220
+ */
2221
+ export async function downloadBootstrap(authorization, target, onProgress) {
2222
+ const response = await fetch(`${Marmot.getURL()}/extensions/web/bootstrap`, {
2223
+ method: "GET",
2224
+ headers: {
2225
+ Authorization: "email" in authorization
2226
+ ? `Token ${authorization.token}`
2227
+ : `ApiKey ${authorization.id}`,
2228
+ ...("code" in target
2229
+ ? { "Access-Code": target.code }
2230
+ : { Server: target.reference }),
2231
+ },
2232
+ });
2233
+ if (!response.ok)
2234
+ throw new Error(await response.text());
2235
+ const contentType = response.headers.get("Content-Type") || "text/html; charset=utf-8";
2236
+ const contentLength = Number(response.headers.get("Content-Length") || "0");
2237
+ const buffer = await readBody(response, contentLength, onProgress);
2238
+ return { html: new TextDecoder().decode(buffer), contentLength, contentType };
2239
+ }
2240
+ /**
2241
+ * Upload one media asset; returns a per-asset access code for guest download.
2242
+ */
2243
+ export function uploadAsset(authorization, target, data, contentType, options = {}) {
2244
+ return new Promise((resolve, reject) => {
2245
+ const xhr = new XMLHttpRequest();
2246
+ xhr.open("POST", `${Marmot.getURL()}/extensions/web/asset`);
2247
+ xhr.setRequestHeader("Authorization", "email" in authorization ? `Token ${authorization.token}` : `ApiKey ${authorization.id}`);
2248
+ if ("code" in target)
2249
+ xhr.setRequestHeader("Access-Code", target.code);
2250
+ else
2251
+ xhr.setRequestHeader("Server", target.reference);
2252
+ const safeType = (contentType || "application/octet-stream").split(";")[0]?.trim() || "application/octet-stream";
2253
+ xhr.setRequestHeader("Content-Type", safeType);
2254
+ if (options.assetId)
2255
+ xhr.setRequestHeader("Asset-Id", options.assetId);
2256
+ xhr.upload.onprogress = (event) => {
2257
+ if (event.lengthComputable && event.total > 0 && options.onProgress) {
2258
+ options.onProgress(Math.min(Math.max(event.loaded / event.total, 0), 1));
2259
+ }
2260
+ };
2261
+ xhr.onload = () => {
2262
+ options.onProgress?.(1);
2263
+ if (xhr.status >= 200 && xhr.status < 300) {
2264
+ try {
2265
+ resolve(JSON.parse(xhr.responseText));
2266
+ }
2267
+ catch (err) {
2268
+ reject(err);
2269
+ }
2270
+ }
2271
+ else {
2272
+ reject(new Error(xhr.responseText || `${xhr.status}: asset upload failed`));
2273
+ }
2274
+ };
2275
+ xhr.onerror = () => reject(new Error("Failed to upload asset."));
2276
+ xhr.send(new Uint8Array(data));
2277
+ });
2278
+ }
2279
+ /** Download one asset by its file access code. */
2280
+ export async function downloadAsset(authorization, accessCode, onProgress) {
2281
+ const response = await fetch(`${Marmot.getURL()}/extensions/web/asset`, {
2282
+ method: "GET",
2283
+ headers: {
2284
+ Authorization: "email" in authorization
2285
+ ? `Token ${authorization.token}`
2286
+ : `ApiKey ${authorization.id}`,
2287
+ "File-Access-Code": accessCode,
2288
+ },
2289
+ });
2290
+ if (!response.ok)
2291
+ throw new Error(await response.text());
2292
+ const contentType = response.headers.get("Content-Type") || "application/octet-stream";
2293
+ const contentLength = Number(response.headers.get("Content-Length") || "0");
2294
+ const data = await readBody(response, contentLength, onProgress);
2295
+ return { data, contentType };
2296
+ }
2297
+ async function readBody(response, contentLength, onProgress) {
2298
+ if (!response.body) {
2299
+ const buf = new Uint8Array(await response.arrayBuffer());
2300
+ onProgress?.(1);
2301
+ return buf;
2302
+ }
2303
+ const reader = response.body.getReader();
2304
+ const chunks = [];
2305
+ let loaded = 0;
2306
+ while (true) {
2307
+ const { done, value } = await reader.read();
2308
+ if (done)
2309
+ break;
2310
+ if (value) {
2311
+ chunks.push(value);
2312
+ loaded += value.byteLength;
2313
+ if (onProgress && contentLength > 0) {
2314
+ onProgress(Math.min(loaded / contentLength, 1));
2315
+ }
2316
+ }
2317
+ }
2318
+ onProgress?.(1);
2319
+ const out = new Uint8Array(loaded);
2320
+ let offset = 0;
2321
+ for (const chunk of chunks) {
2322
+ out.set(chunk, offset);
2323
+ offset += chunk.byteLength;
2324
+ }
2325
+ return out;
2326
+ }
2327
+ /** Aggregate sequential upload/download stages into a single 0..1 progress. */
2328
+ export function weightedProgress(stages, onProgress) {
2329
+ const total = stages.reduce((sum, s) => sum + Math.max(s.weight, 0), 0) || 1;
2330
+ let completed = 0;
2331
+ return stages.reduce(async (prev, stage) => {
2332
+ await prev;
2333
+ const weight = Math.max(stage.weight, 0);
2334
+ await stage.run((p) => {
2335
+ const clamped = Math.min(Math.max(p, 0), 1);
2336
+ onProgress?.((completed + clamped * weight) / total);
2337
+ });
2338
+ completed += weight;
2339
+ onProgress?.(completed / total);
2340
+ }, Promise.resolve());
2341
+ }
2342
+ /**
2343
+ * A live web room for page sharing. `WebRoom extends Room` and carries presence
2344
+ * + small DOM mutations. Initial HTML and media bytes travel over HTTP
2345
+ * bootstrap/asset routes, not this socket.
2346
+ */
2347
+ export class WebRoom extends Room {
2348
+ constructor(ws, authorization, id, hooks, connectionState, opts) {
2349
+ // Room.generateAccessCode POSTs `${endpoint}/access-code`; mountRoom
2350
+ // registers that under the live base, not the service root.
2351
+ super(opts?.endpoint ?? `${Marmot.getURL()}/extensions/web/live`, ws, authorization, id);
2352
+ Object.defineProperty(this, "hooks", {
2353
+ enumerable: true,
2354
+ configurable: true,
2355
+ writable: true,
2356
+ value: void 0
2357
+ });
2358
+ Object.defineProperty(this, "latest", {
2359
+ enumerable: true,
2360
+ configurable: true,
2361
+ writable: true,
2362
+ value: null
2363
+ });
2364
+ Object.defineProperty(this, "bootstrap", {
2365
+ enumerable: true,
2366
+ configurable: true,
2367
+ writable: true,
2368
+ value: null
2369
+ });
2370
+ Object.defineProperty(this, "toolHandler", {
2371
+ enumerable: true,
2372
+ configurable: true,
2373
+ writable: true,
2374
+ value: null
2375
+ });
2376
+ Object.defineProperty(this, "pendingTools", {
2377
+ enumerable: true,
2378
+ configurable: true,
2379
+ writable: true,
2380
+ value: new Map()
2381
+ });
2382
+ /** Tagged host principal from the live connection (`credentials:` / `group:` / …). */
2383
+ Object.defineProperty(this, "hostPrincipal", {
2384
+ enumerable: true,
2385
+ configurable: true,
2386
+ writable: true,
2387
+ value: null
2388
+ });
2389
+ /** Server-computed: whether this caller may invite others to the share. */
2390
+ Object.defineProperty(this, "_canInvite", {
2391
+ enumerable: true,
2392
+ configurable: true,
2393
+ writable: true,
2394
+ value: false
2395
+ });
2396
+ Object.defineProperty(this, "owned", {
2397
+ enumerable: true,
2398
+ configurable: true,
2399
+ writable: true,
2400
+ value: void 0
2401
+ });
2402
+ this.hooks = hooks;
2403
+ this.owned = opts?.owned !== false;
2404
+ const roomMeta = connectionState?.room;
2405
+ if (roomMeta?.hostPrincipal)
2406
+ this.hostPrincipal = String(roomMeta.hostPrincipal);
2407
+ if (typeof roomMeta?.canInvite === "boolean")
2408
+ this._canInvite = roomMeta.canInvite;
2409
+ }
2410
+ setHooks(hooks) {
2411
+ this.hooks = { ...this.hooks, ...hooks };
2412
+ }
2413
+ /** Ingest-only child of a BrowserRoom (same socket). */
2414
+ static fromParent(parent, hooks = {}, connectionState) {
2415
+ return new WebRoom(parent.ws, parent.authorization, parent.id, hooks, connectionState, {
2416
+ owned: false,
2417
+ endpoint: parent.endpoint,
2418
+ });
2419
+ }
2420
+ /** Host principal tagged string, if the connection provided it. */
2421
+ getHostPrincipal() {
2422
+ return this.hostPrincipal;
2423
+ }
2424
+ /** Whether this signed-in caller may invite others (server-gated). */
2425
+ canInvite() {
2426
+ return this._canInvite;
2427
+ }
2428
+ getSnapshot() {
2429
+ return this.latest;
2430
+ }
2431
+ getBootstrap() {
2432
+ return this.bootstrap;
2433
+ }
2434
+ disconnect() {
2435
+ if (!this.owned)
2436
+ return;
2437
+ super.disconnect();
2438
+ }
2439
+ ingestSnapshot(snapshot) {
2440
+ this.latest = snapshot;
2441
+ this.hooks.onSnapshot?.(this, snapshot);
2442
+ }
2443
+ ingestBootstrap(meta) {
2444
+ this.bootstrap = meta;
2445
+ this.hooks.onBootstrap?.(this, meta);
2446
+ }
2447
+ publishBootstrap(meta) {
2448
+ this.bootstrap = meta;
2449
+ this.post({ event: "web-bootstrap", data: { bootstrap: meta } });
2450
+ }
2451
+ publishSnapshot(snapshot) {
2452
+ this.latest = snapshot;
2453
+ this.post({ event: "web-snapshot", data: { snapshot } });
2454
+ }
2455
+ publishMutation(mutation) {
2456
+ this.post({ event: "web-mutation", data: { mutation } });
2457
+ }
2458
+ publishClosed() {
2459
+ this.post({ event: "web-closed", data: {} });
2460
+ }
2461
+ /** Host: fulfill inbound `web-tool-call` frames. */
2462
+ setToolHandler(handler) {
2463
+ this.toolHandler = handler;
2464
+ return () => {
2465
+ if (this.toolHandler === handler)
2466
+ this.toolHandler = null;
2467
+ };
2468
+ }
2469
+ /** Any member: ask the host to run a page tool. */
2470
+ invokeTool(name, params = {}, timeoutMs = 30000) {
2471
+ if (this.toolHandler) {
2472
+ return this.toolHandler(name, params);
2473
+ }
2474
+ const id = crypto.randomUUID();
2475
+ return new Promise((resolve, reject) => {
2476
+ const timer = setTimeout(() => {
2477
+ this.pendingTools.delete(id);
2478
+ reject(new Error(`Tool timed out: ${name}`));
2479
+ }, timeoutMs);
2480
+ this.pendingTools.set(id, {
2481
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
2482
+ reject: (e) => { clearTimeout(timer); reject(e); },
2483
+ });
2484
+ this.post({ event: "web-tool-call", data: { id, name, params } });
2485
+ });
2486
+ }
2487
+ handleEvent(packet) {
2488
+ switch (packet.event) {
2489
+ case "web-bootstrap": {
2490
+ this.bootstrap = packet.data?.bootstrap ?? null;
2491
+ if (this.bootstrap)
2492
+ this.hooks.onBootstrap?.(this, this.bootstrap);
2493
+ return true;
2494
+ }
2495
+ case "web-snapshot": {
2496
+ this.latest = packet.data?.snapshot ?? null;
2497
+ if (this.latest)
2498
+ this.hooks.onSnapshot?.(this, this.latest);
2499
+ return true;
2500
+ }
2501
+ case "web-mutation": {
2502
+ if (packet.data?.mutation)
2503
+ this.hooks.onMutation?.(this, packet.data.mutation);
2504
+ return true;
2505
+ }
2506
+ case "web-tool-call": {
2507
+ void this.handleToolCall(packet.data);
2508
+ return true;
2509
+ }
2510
+ case "web-tool-result": {
2511
+ const id = packet.data?.id;
2512
+ if (!id)
2513
+ return true;
2514
+ const pending = this.pendingTools.get(id);
2515
+ if (!pending)
2516
+ return true;
2517
+ this.pendingTools.delete(id);
2518
+ if (packet.data?.error)
2519
+ pending.reject(new Error(String(packet.data.error)));
2520
+ else
2521
+ pending.resolve(String(packet.data?.result ?? ""));
2522
+ return true;
2523
+ }
2524
+ case "web-closed":
2525
+ case "server-closed": {
2526
+ this.hooks.onClosed?.(this);
2527
+ return true;
2528
+ }
2529
+ default:
2530
+ return false;
2531
+ }
2532
+ }
2533
+ async handleToolCall(data) {
2534
+ const id = data?.id;
2535
+ const name = data?.name;
2536
+ if (!id || !name || !this.toolHandler)
2537
+ return;
2538
+ try {
2539
+ const result = await this.toolHandler(String(name), data?.params ?? {});
2540
+ this.post({ event: "web-tool-result", data: { id, result } });
2541
+ }
2542
+ catch (err) {
2543
+ this.post({
2544
+ event: "web-tool-result",
2545
+ data: { id, error: err?.message ?? String(err) },
2546
+ });
2547
+ }
2548
+ }
2549
+ }
2550
+ /**
2551
+ * Connect to a web room. `target` is an access code (viewer joining a shared
2552
+ * page) or a room reference (host opening its own room).
2553
+ */
2554
+ export async function connectWebRoom(authorization, target, hooks = {}) {
2555
+ const auth = "code" in target
2556
+ ? { authorization, accessCode: target.code }
2557
+ : { authorization, server: target.reference };
2558
+ let created;
2559
+ const notify = () => {
2560
+ if (created)
2561
+ hooks.onPresence?.(created);
2562
+ };
2563
+ const self = await connect(auth, `${Marmot.getURL()}/extensions/web/live`, {
2564
+ createRoom: (_endpoint, ws, a, id, state) => {
2565
+ created = new WebRoom(ws, a, id, hooks, state);
2566
+ if (state?.bootstrap)
2567
+ created.ingestBootstrap(state.bootstrap);
2568
+ if (state?.snapshot)
2569
+ created.ingestSnapshot(state.snapshot);
2570
+ return created;
2571
+ },
2572
+ createPresence: (room, data) => {
2573
+ const presence = new Presence(room, data.id, data.profile);
2574
+ presence.update(data);
2575
+ queueMicrotask(notify);
2576
+ return presence;
2577
+ },
2578
+ removePresence: () => {
2579
+ queueMicrotask(notify);
2580
+ },
2581
+ onEvent: (room, packet) => room.handleEvent(packet),
2582
+ onClosed: (room) => hooks.onClosed?.(room),
2583
+ });
2584
+ notify();
2585
+ return { room: created, self };
2586
+ }