dom.mjs raw
1 // TinyJS Runtime — DOM Bridge
2 // Provides Go-callable browser DOM operations.
3 // Elements are tracked by integer handles.
4 // Function names match Go signatures (PascalCase).
5
6
7 const _elements = new Map();
8 const _nodeToId = new WeakMap();
9 const _elemCallbacks = new Map();
10 const _cbEventInfo = new Map(); // cbId -> {el, event, fn}
11 const _resizeObservers = new Map(); // elId -> ResizeObserver
12 let _nextId = 1; // Safe up to 2^53 in JS. No wrap needed.
13 const _callbacks = new Map();
14 let _nextCb = 1;
15 let _onCallbacksReleased = null;
16 export function setCallbackReleaseHook(fn) { _onCallbacksReleased = fn; }
17
18 // Instrumentation: log _elements.size at thresholds when __moxie_instrument is set.
19 const _elemThresholds = [100, 500, 1000, 2000, 5000];
20 let _elemThresholdIdx = 0;
21 function _checkElemThreshold() {
22 if (!globalThis.__moxie_instrument) return;
23 const sz = _elements.size;
24 while (_elemThresholdIdx < _elemThresholds.length && sz >= _elemThresholds[_elemThresholdIdx]) {
25 const log = globalThis.__moxie_alloc_log;
26 if (Array.isArray(log)) {
27 if (log.length >= 10000) log.shift();
28 log.push({ ts: Date.now(), source: 'dom', metric: 'elements_size', value: sz });
29 }
30 _elemThresholdIdx++;
31 }
32 }
33
34 // Initialize the ring buffer on first use if instrumentation is active.
35 if (typeof globalThis.__moxie_alloc_log === 'undefined') {
36 globalThis.__moxie_alloc_log = [];
37 }
38
39 // Register a new element handle and return its ID.
40 function _trackEl(el) {
41 const id = _nextId++;
42 _elements.set(id, el);
43 _nodeToId.set(el, id);
44 _checkElemThreshold();
45 return id;
46 }
47
48 // Bootstrap: map handle 0 to document.body (available after DOMContentLoaded).
49 function ensureBody() {
50 if (!_elements.has(0) && typeof document !== 'undefined' && document.body) {
51 _elements.set(0, document.body);
52 }
53 }
54
55 // Element creation and lookup.
56 export function CreateElement(tag) {
57 return _trackEl(document.createElement(tag));
58 }
59
60 export function CreateTextNode(text) {
61 return _trackEl(document.createTextNode(text));
62 }
63
64 export function GetElementById(id) {
65 const el = document.getElementById(id);
66 return el ? _trackEl(el) : 0;
67 }
68
69 export function QuerySelector(sel) {
70 const el = document.querySelector(sel);
71 return el ? _trackEl(el) : 0;
72 }
73
74 export function QuerySelectorFrom(rootId, sel) {
75 const root = _elements.get(rootId);
76 if (!root) return 0;
77 const el = root.querySelector(sel);
78 return el ? _trackEl(el) : 0;
79 }
80
81 export function Body() {
82 ensureBody();
83 return 0;
84 }
85
86 // Tree manipulation.
87 export function AppendChild(parentId, childId) {
88 const parent = _elements.get(parentId);
89 const child = _elements.get(childId);
90 if (parent && child) parent.appendChild(child);
91 }
92
93 export function RemoveChild(parentId, childId) {
94 const parent = _elements.get(parentId);
95 const child = _elements.get(childId);
96 if (parent && child) parent.removeChild(child);
97 }
98
99 export function Remove(elId) {
100 const el = _elements.get(elId);
101 if (el && el.parentNode) el.parentNode.removeChild(el);
102 }
103
104 export function FirstChild(parentId) {
105 const parent = _elements.get(parentId);
106 return (parent && parent.firstChild) ? _trackEl(parent.firstChild) : 0;
107 }
108
109 export function FirstElementChild(parentId) {
110 const parent = _elements.get(parentId);
111 return (parent && parent.firstElementChild) ? _trackEl(parent.firstElementChild) : 0;
112 }
113
114 export function InsertBefore(parentId, newId, refId) {
115 const parent = _elements.get(parentId);
116 const newEl = _elements.get(newId);
117 const ref = refId >= 0 ? _elements.get(refId) : null;
118 if (parent && newEl) parent.insertBefore(newEl, ref);
119 }
120
121 export function ReplaceChild(parentId, newId, oldId) {
122 const parent = _elements.get(parentId);
123 const newEl = _elements.get(newId);
124 const oldEl = _elements.get(oldId);
125 if (parent && newEl && oldEl) parent.replaceChild(newEl, oldEl);
126 }
127
128 // Contenteditable support — makes contenteditable divs behave like textareas
129 // for Moxie code that reads/writes "value", "selectionStart", "selectionEnd".
130
131 let _cePlaceholderCSS = false;
132
133 function _ceSetup(el) {
134 if (!_cePlaceholderCSS) {
135 const s = document.createElement('style');
136 s.textContent = '[data-placeholder]:empty::before{content:attr(data-placeholder);color:var(--muted);pointer-events:none;}[data-placeholder] br:only-child{display:none;}';
137 document.head.appendChild(s);
138 _cePlaceholderCSS = true;
139 }
140 el.style.outline = 'none';
141 const ph = el.getAttribute('placeholder');
142 if (ph) { el.setAttribute('data-placeholder', ph); el.removeAttribute('placeholder'); }
143 // Firefox bug 685799: Backspace/Delete can't remove contenteditable=false
144 // inline elements. Detect deletion adjacent to a chip and remove it manually.
145 el.addEventListener('beforeinput', function(e) {
146 if (e.inputType !== 'deleteContentBackward' && e.inputType !== 'deleteContentForward') return;
147 const sel = window.getSelection();
148 if (!sel.rangeCount || !sel.isCollapsed) return;
149 const r = sel.getRangeAt(0);
150 let target = null;
151 if (e.inputType === 'deleteContentBackward') {
152 if (r.startContainer === el) {
153 const prev = el.childNodes[r.startOffset - 1];
154 if (prev && prev.dataset && prev.dataset.npub) target = prev;
155 } else if (r.startContainer.nodeType === 3 && r.startOffset === 0) {
156 const prev = r.startContainer.previousSibling;
157 if (prev && prev.dataset && prev.dataset.npub) target = prev;
158 }
159 } else {
160 if (r.startContainer === el) {
161 const next = el.childNodes[r.startOffset];
162 if (next && next.dataset && next.dataset.npub) target = next;
163 } else if (r.startContainer.nodeType === 3 && r.startOffset === r.startContainer.textContent.length) {
164 const next = r.startContainer.nextSibling;
165 if (next && next.dataset && next.dataset.npub) target = next;
166 }
167 }
168 if (target) {
169 e.preventDefault();
170 target.remove();
171 el.dispatchEvent(new Event('input', { bubbles: true }));
172 }
173 });
174 }
175
176 function _isCE(el) { return el && (el.contentEditable === 'true' || el.contentEditable === 'plaintext-only'); }
177
178 function _ceExtract(el, stopNode, stopOff) {
179 let text = '';
180 let stopped = false;
181 let resultOff = 0;
182 function walk(node) {
183 if (stopped) return;
184 if (stopNode && node === stopNode) {
185 if (node.nodeType === 3) {
186 text += node.textContent.substring(0, stopOff);
187 } else {
188 let c = 0;
189 for (const ch of node.childNodes) { if (c >= stopOff) break; walk(ch); c++; }
190 }
191 resultOff = text.length;
192 stopped = true;
193 return;
194 }
195 if (node.nodeType === 3) { text += node.textContent; }
196 else if (node.nodeName === 'BR') { text += '\n'; }
197 else if (node.dataset && node.dataset.npub) { text += 'nostr:' + node.dataset.npub; }
198 else {
199 if ((node.nodeName === 'DIV' || node.nodeName === 'P') && text.length > 0 && text[text.length - 1] !== '\n') text += '\n';
200 for (const ch of node.childNodes) { if (stopped) return; walk(ch); }
201 }
202 }
203 for (const ch of el.childNodes) { if (stopped) break; walk(ch); }
204 if (!stopped) resultOff = text.length;
205 return { text, offset: resultOff };
206 }
207
208 function _ceGetValue(el) { return _ceExtract(el).text; }
209
210 function _ceGetSelOff(el) {
211 const sel = window.getSelection();
212 if (!sel.rangeCount || !el.contains(sel.focusNode)) return 0;
213 return _ceExtract(el, sel.focusNode, sel.focusOffset).offset;
214 }
215
216 function _ceSetSelOff(el, target) {
217 let rem = target;
218 let rNode = el, rOff = 0;
219 let done = false;
220 function walk(node) {
221 if (done) return;
222 if (node.nodeType === 3) {
223 if (rem <= node.textContent.length) { rNode = node; rOff = rem; done = true; return; }
224 rem -= node.textContent.length;
225 } else if (node.nodeName === 'BR') {
226 if (rem <= 1) { rNode = node.parentNode; rOff = Array.from(node.parentNode.childNodes).indexOf(node) + 1; done = true; return; }
227 rem -= 1;
228 } else if (node.dataset && node.dataset.npub) {
229 const len = ('nostr:' + node.dataset.npub).length;
230 if (rem <= len) { rNode = node.parentNode; rOff = Array.from(node.parentNode.childNodes).indexOf(node) + 1; done = true; return; }
231 rem -= len;
232 } else {
233 if ((node.nodeName === 'DIV' || node.nodeName === 'P') && rem > 0) rem--;
234 for (const ch of node.childNodes) { if (done) return; walk(ch); }
235 }
236 }
237 for (const ch of el.childNodes) { if (done) break; walk(ch); }
238 if (!done) { rNode = el; rOff = el.childNodes.length; }
239 try {
240 const sel = window.getSelection();
241 const range = document.createRange();
242 range.setStart(rNode, rOff);
243 range.collapse(true);
244 sel.removeAllRanges();
245 sel.addRange(range);
246 } catch(e) {}
247 }
248
249 function _ceSetValue(el, text) {
250 while (el.firstChild) el.removeChild(el.firstChild);
251 if (!text) return;
252 const re = /(nostr:npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/;
253 const parts = text.split(re);
254 for (const part of parts) {
255 if (part.match(re)) {
256 el.appendChild(_ceChip(part.slice(6), '', ''));
257 } else {
258 const lines = part.split('\n');
259 for (let i = 0; i < lines.length; i++) {
260 if (i > 0) el.appendChild(document.createElement('br'));
261 if (lines[i]) el.appendChild(document.createTextNode(lines[i]));
262 }
263 }
264 }
265 }
266
267 function _ceChip(npub, name, pic) {
268 const s = document.createElement('span');
269 s.contentEditable = 'false';
270 s.dataset.npub = npub;
271 s.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(100,100,255,0.15);border-radius:4px;padding:1px 5px;font-size:13px;vertical-align:baseline;cursor:default;user-select:all;line-height:1.4;';
272 if (pic) {
273 const img = document.createElement('img');
274 img.src = pic;
275 img.style.cssText = 'width:16px;height:16px;border-radius:50%;object-fit:cover;';
276 s.appendChild(img);
277 }
278 s.appendChild(document.createTextNode(name || ('@' + npub.slice(0, 12) + '…')));
279 return s;
280 }
281
282 export function InsertMentionChip(elId, charCount, npub, name, pic) {
283 const el = _elements.get(elId);
284 if (!el) return;
285 if (_isCE(el)) {
286 el.focus();
287 const sel = window.getSelection();
288 if (!sel.rangeCount) return;
289 for (let i = 0; i < charCount; i++) sel.modify('extend', 'backward', 'character');
290 const range = sel.getRangeAt(0);
291 range.deleteContents();
292 const chip = _ceChip(npub, name, pic);
293 range.insertNode(chip);
294 const space = document.createTextNode(' ');
295 if (chip.nextSibling) chip.parentNode.insertBefore(space, chip.nextSibling);
296 else chip.parentNode.appendChild(space);
297 range.setStartAfter(space);
298 range.collapse(true);
299 sel.removeAllRanges();
300 sel.addRange(range);
301 el.dispatchEvent(new Event('input', { bubbles: true }));
302 } else {
303 const val = el.value || '';
304 const cur = el.selectionStart || 0;
305 const mention = 'nostr:' + npub + ' ';
306 const start = cur - charCount;
307 el.value = val.substring(0, start) + mention + val.substring(cur);
308 const nc = start + mention.length;
309 el.selectionStart = nc;
310 el.selectionEnd = nc;
311 }
312 }
313
314 // Properties and attributes.
315 export function SetAttribute(elId, name, value) {
316 const el = _elements.get(elId);
317 if (!el || !el.setAttribute) return;
318 if (name === 'contenteditable' && (value === 'true' || value === 'plaintext-only')) {
319 el.setAttribute(name, value);
320 _ceSetup(el);
321 return;
322 }
323 if (name === 'placeholder' && _isCE(el)) {
324 el.setAttribute('data-placeholder', value);
325 return;
326 }
327 el.setAttribute(name, value);
328 }
329
330 export function RemoveAttribute(elId, name) {
331 const el = _elements.get(elId);
332 if (el && el.removeAttribute) el.removeAttribute(name);
333 }
334
335 export function GetAttribute(elId, name) {
336 const el = _elements.get(elId);
337 return (el && el.getAttribute) ? (el.getAttribute(name) || '') : '';
338 }
339
340 export function SetTextContent(elId, text) {
341 const el = _elements.get(elId);
342 if (el) el.textContent = text;
343 }
344
345 export function SetInnerHTML(elId, html) {
346 const el = _elements.get(elId);
347 if (!el) return;
348 if (!html) {
349 if (el.replaceChildren) el.replaceChildren();
350 else while (el.firstChild) el.removeChild(el.firstChild);
351 return;
352 }
353 const tpl = document.createElement('template');
354 const prop = 'inner' + 'HTML';
355 tpl[prop] = html;
356 if (el.replaceChildren) el.replaceChildren(tpl.content);
357 else {
358 while (el.firstChild) el.removeChild(el.firstChild);
359 el.appendChild(tpl.content);
360 }
361 }
362
363 export function SetStyle(elId, prop, value) {
364 const el = _elements.get(elId);
365 if (el && el.style) el.style[prop] = value;
366 }
367
368 export function Focus(elId) {
369 const el = _elements.get(elId);
370 if (el && el.focus) el.focus();
371 }
372
373 export function SetProperty(elId, prop, value) {
374 const el = _elements.get(elId);
375 if (!el) return;
376 if (_isCE(el)) {
377 if (prop === 'value') { _ceSetValue(el, value); return; }
378 if (prop === 'selectionStart' || prop === 'selectionEnd') { _ceSetSelOff(el, parseInt(value) || 0); return; }
379 }
380 el[prop] = value;
381 }
382
383 export function GetProperty(elId, prop) {
384 const el = _elements.get(elId);
385 if (!el) return '';
386 if (_isCE(el)) {
387 if (prop === 'value') return _ceGetValue(el);
388 if (prop === 'selectionStart' || prop === 'selectionEnd') return String(_ceGetSelOff(el));
389 }
390 return String(el[prop] ?? '');
391 }
392
393 export function BoundingClientLeft(elId) {
394 const el = _elements.get(elId);
395 if (!el || !el.getBoundingClientRect) return 0;
396 return el.getBoundingClientRect().left | 0;
397 }
398
399 export function GetBoundingRect(elId, fn) {
400 const el = _elements.get(elId);
401 if (!el || !el.getBoundingClientRect) { fn(0, 0, 0, 0, 0, 0); return; }
402 const r = el.getBoundingClientRect();
403 fn(Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom), Math.round(r.width), Math.round(r.height));
404 }
405
406 export function GetViewportHeight() {
407 return window.innerHeight | 0;
408 }
409
410 export function GetViewportWidth() {
411 return window.innerWidth | 0;
412 }
413
414 export function AddClass(elId, cls) {
415 const el = _elements.get(elId);
416 if (el && el.classList) el.classList.add(cls);
417 }
418
419 export function RemoveClass(elId, cls) {
420 const el = _elements.get(elId);
421 if (el && el.classList) el.classList.remove(cls);
422 }
423
424 // Events.
425 export function AddEventListener(elId, event, callbackId) {
426 const el = _elements.get(elId);
427 const cb = _callbacks.get(callbackId);
428 if (el && cb) {
429 el.addEventListener(event, cb);
430 let set = _elemCallbacks.get(elId);
431 if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
432 set.add(callbackId);
433 _cbEventInfo.set(callbackId, { el, event, fn: cb });
434 }
435 }
436
437 // AddEnterKeyListener fires callback when Enter is pressed on the element.
438 export function AddEnterKeyListener(elId, callbackId) {
439 const el = _elements.get(elId);
440 const cb = _callbacks.get(callbackId);
441 if (el && cb) {
442 const wrapper = function(e) { if (e.key === 'Enter') { e.preventDefault(); cb(); } };
443 el.addEventListener('keydown', wrapper);
444 let set = _elemCallbacks.get(elId);
445 if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
446 set.add(callbackId);
447 _cbEventInfo.set(callbackId, { el, event: 'keydown', fn: wrapper });
448 }
449 }
450
451 // OnKeydown registers a keydown handler that calls fn(key, prevent).
452 // fn receives the key name as a string. To preventDefault, fn should call
453 // the prevent callback.
454 export function OnKeydown(elId, fn) {
455 const el = _elements.get(elId);
456 if (!el) return;
457 el.addEventListener('keydown', function(e) {
458 let stopped = false;
459 fn(e.key, function() { stopped = true; });
460 if (stopped) { e.preventDefault(); e.stopPropagation(); }
461 });
462 }
463
464 // Like AddEventListener but only fires when e.target === e.currentTarget.
465 // Useful for backdrop click-to-close without catching child clicks.
466 export function AddSelfEventListener(elId, event, callbackId) {
467 const el = _elements.get(elId);
468 const cb = _callbacks.get(callbackId);
469 if (el && cb) {
470 const wrapper = function(e) { if (e.target === e.currentTarget) cb(); };
471 el.addEventListener(event, wrapper);
472 let set = _elemCallbacks.get(elId);
473 if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
474 set.add(callbackId);
475 _cbEventInfo.set(callbackId, { el, event, fn: wrapper });
476 }
477 }
478
479 export function RemoveEventListener(elId, event, callbackId) {
480 const el = _elements.get(elId);
481 const cb = _callbacks.get(callbackId);
482 if (el && cb) el.removeEventListener(event, cb);
483 }
484
485 export function ObserveResize(elId, callbackId) {
486 const el = _elements.get(elId);
487 const cb = _callbacks.get(callbackId);
488 if (!el || !cb) return;
489 const old = _resizeObservers.get(elId);
490 if (old) old.disconnect();
491 const ro = new ResizeObserver(() => cb());
492 ro.observe(el);
493 _resizeObservers.set(elId, ro);
494 }
495
496 export function UnobserveResize(elId) {
497 const ro = _resizeObservers.get(elId);
498 if (ro) { ro.disconnect(); _resizeObservers.delete(elId); }
499 }
500
501 export function getElement(id) { return _elements.get(id); }
502
503 // Register a Go function as a JS callback. Returns callback ID.
504 export function RegisterCallback(fn) {
505 const id = _nextCb++;
506 _callbacks.set(id, fn);
507 return id;
508 }
509
510 // Counts for the instrumentation hooks the wasm host exposes as
511 // window.__moxie_element_count / window.__moxie_callback_count, used by the
512 // memory tests. Tracked maps are the same ones the release paths drain.
513 export function ElementCount() { return _elements.size; }
514 export function CallbackCount() { return _callbacks.size; }
515
516 export function ReleaseCallback(id) {
517 _callbacks.delete(id);
518 }
519
520 // Scheduling.
521 export function RequestAnimationFrame(fn) {
522 if (typeof window !== 'undefined') {
523 window.requestAnimationFrame(fn);
524 } else {
525 setTimeout(fn, 16);
526 }
527 }
528
529 export function SetTimeout(fn, ms) {
530 return setTimeout(fn, ms);
531 }
532
533 export function SetInterval(fn, ms) {
534 return setInterval(fn, ms);
535 }
536
537 export function ClearInterval(id) {
538 clearInterval(id);
539 }
540
541 export function ClearTimeout(id) {
542 clearTimeout(id);
543 }
544
545 function _releaseHandle(id, el, released) {
546 if (el) {
547 _nodeToId.delete(el);
548 if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
549 const src = el.src;
550 if (src && src.startsWith('blob:')) URL.revokeObjectURL(src);
551 }
552 }
553 const ro = _resizeObservers.get(id);
554 if (ro) { ro.disconnect(); _resizeObservers.delete(id); }
555 _elements.delete(id);
556 const cbs = _elemCallbacks.get(id);
557 if (cbs) {
558 cbs.forEach(cbId => {
559 const info = _cbEventInfo.get(cbId);
560 if (info) {
561 try { info.el.removeEventListener(info.event, info.fn); } catch(_) {}
562 _cbEventInfo.delete(cbId);
563 }
564 _callbacks.delete(cbId);
565 if (released) released.push(cbId);
566 });
567 _elemCallbacks.delete(id);
568 }
569 }
570
571 // Cleanup: release element handle.
572 export function ReleaseElement(id) {
573 const released = _onCallbacksReleased ? [] : null;
574 _releaseHandle(id, _elements.get(id), released);
575 if (released && released.length > 0) _onCallbacksReleased(released);
576 }
577
578 // Bulk release: drop all handles in the ids array.
579 export function ReleaseAll(ids) {
580 const released = _onCallbacksReleased ? [] : null;
581 for (let i = 0; i < ids.length; i++) _releaseHandle(ids[i], _elements.get(ids[i]), released);
582 if (released && released.length > 0) _onCallbacksReleased(released);
583 }
584
585 // ReleaseChildren releases handles, blob URLs, and callbacks for all DOM descendants.
586 export function ReleaseChildren(elId) {
587 const root = _elements.get(elId);
588 if (!root) return;
589 const released = _onCallbacksReleased ? [] : null;
590 const stack = [];
591 const ch = root.children;
592 for (let i = 0; i < ch.length; i++) stack.push(ch[i]);
593 while (stack.length > 0) {
594 const node = stack.pop();
595 const sub = node.children;
596 for (let i = 0; i < sub.length; i++) stack.push(sub[i]);
597 const id = _nodeToId.get(node);
598 if (id !== undefined) _releaseHandle(id, node, released);
599 }
600 if (released && released.length > 0) _onCallbacksReleased(released);
601 }
602
603 // Fetch a URL as text, call fn with result.
604 export function FetchText(url, fn) {
605 fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); }).catch(() => { if (fn) fn(''); });
606 }
607
608 // Navigation.
609 export function NextSibling(elId) {
610 const el = _elements.get(elId);
611 return (el && el.nextSibling) ? _trackEl(el.nextSibling) : 0;
612 }
613
614 // Console / browser.
615 export function ConsoleLog(msg) {
616 console.log(msg);
617 }
618
619 export function PrefersDark() {
620 if (typeof window !== 'undefined' && window.matchMedia) {
621 return window.matchMedia('(prefers-color-scheme: dark)').matches;
622 }
623 return false;
624 }
625
626 export function Confirm(msg) {
627 if (typeof window !== 'undefined') return window.confirm(msg);
628 return false;
629 }
630
631 // Service Worker communication — with direct WebSocket fallback for insecure contexts.
632 let _swMessageHandler = null;
633 let _swQueue = [];
634 const _hasSW = typeof navigator !== 'undefined' && 'serviceWorker' in navigator &&
635 (typeof self !== 'undefined' && self.isSecureContext);
636
637 // ========================================================================
638 // Direct WebSocket relay fallback (active when SW unavailable)
639 // Handles PROXY, REQ, CLOSE, EVENT messages using NIP-01 WebSocket protocol.
640 // ========================================================================
641
642 let _wsConns = {}; // url -> WebSocket
643 let _wsSubs = {}; // subID -> { filter: string, relays: Set<url> }
644 let _wsSeen = {}; // subID -> { eventID: true }
645 let _wsEoseCount = {}; // subID -> count of relays that sent EOSE
646 let _wsWriteRelays = []; // relay URLs for EVENT publishing
647
648 function _diag(msg) {
649 console.log(msg);
650 }
651 let _wsPendingPub = {}; // url -> [json strings] queued while CONNECTING
652
653 function _wsSend(handler, msg) {
654 if (handler) handler(msg);
655 }
656
657 // Count an EOSE (or CLOSED) for a sub. Fire EOSE to the app on the FIRST
658 // relay response — matches the SW behaviour and prevents auth-requiring
659 // relays from blocking the threshold.
660 let _wsEoseFired = {};
661 function _wsCountEose(sid) {
662 if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
663 _wsEoseCount[sid]++;
664 if (!_wsEoseFired[sid]) {
665 _wsEoseFired[sid] = true;
666 _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
667 }
668 }
669
670 function _wsEnsure(url) {
671 if (_wsConns[url]) return _wsConns[url];
672 var ws;
673 try { ws = new WebSocket(url); } catch(e) { return null; }
674 _wsConns[url] = ws;
675 ws.onopen = function() {
676 _diag('WS OPEN ' + url);
677 // Replay active subs for this relay.
678 for (var sid in _wsSubs) {
679 var sub = _wsSubs[sid];
680 if (sub.relays.has(url)) {
681 ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
682 }
683 }
684 // Flush queued publishes.
685 var pending = _wsPendingPub[url];
686 if (pending) {
687 _diag('FLUSH ' + pending.length + ' queued to ' + url);
688 delete _wsPendingPub[url];
689 for (var p = 0; p < pending.length; p++) ws.send(pending[p]);
690 }
691 };
692 ws.onmessage = function(e) {
693 var msg;
694 try { msg = JSON.parse(e.data); } catch(ex) { return; }
695 if (!Array.isArray(msg)) return;
696 if (msg[0] === "EVENT" && msg.length >= 3) {
697 var sid = msg[1], ev = msg[2];
698 if (!_wsSeen[sid]) _wsSeen[sid] = {};
699 if (ev.id && _wsSeen[sid][ev.id]) return;
700 if (ev.id) {
701 _wsSeen[sid][ev.id] = true;
702 if (!_wsSeen[sid]._n) _wsSeen[sid]._n = 0;
703 if (++_wsSeen[sid]._n > 5000) {
704 var keys = Object.keys(_wsSeen[sid]);
705 var keep = {}; var n = 0;
706 for (var k = Math.floor(keys.length / 2); k < keys.length; k++) {
707 if (keys[k] !== '_n') { keep[keys[k]] = true; n++; }
708 }
709 keep._n = n;
710 _wsSeen[sid] = keep;
711 }
712 }
713 _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
714 if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
715 } else if (msg[0] === "EOSE" && msg.length >= 2) {
716 _wsCountEose(msg[1]);
717 } else if (msg[0] === "CLOSED" && msg.length >= 2) {
718 _diag('CLOSED ' + url + ' sub=' + msg[1] + ' reason=' + (msg[2] || ''));
719 _wsCountEose(msg[1]);
720 } else if (msg[0] === "OK" && msg.length >= 3) {
721 var okId = msg[1], okSuccess = msg[2], okMsg = msg[3] || '';
722 if (okSuccess) { _diag('OK ' + url + ' id=' + (okId && okId.substring(0,8))); }
723 else { _diag('REJECTED ' + url + ' ' + (okId && okId.substring(0,8)) + ': ' + okMsg); }
724 } else if (msg[0] === "NOTICE") {
725 _diag('NOTICE ' + url + ': ' + msg[1]);
726 }
727 };
728 ws.onclose = function() {
729 _diag('WS CLOSE ' + url);
730 delete _wsConns[url];
731 // Trigger EOSE for subs that included this relay (connection died before EOSE).
732 for (var sid in _wsSubs) {
733 if (_wsSubs[sid].relays.has(url)) _wsCountEose(sid);
734 }
735 };
736 ws.onerror = function() { ws.close(); };
737 return ws;
738 }
739
740 function _wsCloseSub(sid) {
741 var sub = _wsSubs[sid];
742 if (!sub) return;
743 sub.relays.forEach(function(url) {
744 var ws = _wsConns[url];
745 if (ws && ws.readyState === WebSocket.OPEN) {
746 try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
747 }
748 });
749 delete _wsSubs[sid];
750 delete _wsSeen[sid];
751 delete _wsEoseCount[sid];
752 delete _wsEoseFired[sid];
753 }
754
755 function _wsHandleMsg(raw) {
756 var arr;
757 try { arr = JSON.parse(raw); } catch(e) { return; }
758 if (!Array.isArray(arr)) return;
759 var cmd = arr[0];
760 if (cmd === "SET_PUBKEY") {
761 // no-op for direct WS
762 } else if (cmd === "SET_WRITE_RELAYS") {
763 _wsWriteRelays = arr[1] || [];
764 for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
765 } else if (cmd === "PROXY") {
766 var sid = arr[1], filter = arr[2], relays = arr[3] || [];
767 _diag('PROXY ' + sid + ' relays=' + relays.length);
768 _wsCloseSub(sid);
769 _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
770 _wsSeen[sid] = {};
771 _wsEoseCount[sid] = 0;
772 for (var j = 0; j < relays.length; j++) {
773 var url = relays[j];
774 var ws = _wsConns[url];
775 // Drop dead connections (CLOSING/CLOSED) so _wsEnsure creates a fresh one.
776 if (ws && ws.readyState > 1) {
777 _diag('PROXY ' + sid + ' drop dead ws ' + url + ' state=' + ws.readyState);
778 delete _wsConns[url];
779 ws = null;
780 }
781 ws = _wsEnsure(url);
782 if (ws && ws.readyState === WebSocket.OPEN) {
783 _diag('PROXY ' + sid + ' REQ ' + url);
784 ws.send(JSON.stringify(["REQ", sid, filter]));
785 } else {
786 _diag('PROXY ' + sid + ' DEFER ' + url + ' state=' + (ws ? ws.readyState : 'null'));
787 }
788 }
789 } else if (cmd === "REQ") {
790 var sid2 = arr[1], filter2 = arr[2];
791 var url = _wsWriteRelays[0];
792 if (url) {
793 _wsCloseSub(sid2);
794 _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
795 _wsSeen[sid2] = {};
796 _wsEoseCount[sid2] = 0;
797 var ws2 = _wsEnsure(url);
798 if (ws2 && ws2.readyState === WebSocket.OPEN) {
799 ws2.send(JSON.stringify(["REQ", sid2, filter2]));
800 }
801 }
802 } else if (cmd === "CLOSE") {
803 _wsCloseSub(arr[1]);
804 } else if (cmd === "PUBLISH_TO") {
805 var ev2 = arr[1], relays2 = arr[2] || [];
806 _diag('PUBLISH_TO kind=' + (ev2 && ev2.kind) + ' id=' + (ev2 && ev2.id && ev2.id.substring(0,8)) + ' relays=' + relays2.length);
807 var data2 = JSON.stringify(["EVENT", ev2]);
808 for (var m = 0; m < relays2.length; m++) {
809 var ws4 = _wsEnsure(relays2[m]);
810 if (ws4 && ws4.readyState === WebSocket.OPEN) { _diag('SEND ' + relays2[m]); ws4.send(data2); }
811 else if (ws4) { _diag('QUEUE ' + relays2[m] + ' state=' + ws4.readyState); if (!_wsPendingPub[relays2[m]]) _wsPendingPub[relays2[m]] = []; _wsPendingPub[relays2[m]].push(data2); }
812 else { _diag('FAIL no WS ' + relays2[m]); }
813 }
814 } else if (cmd === "EVENT") {
815 _diag('EVENT kind=' + (arr[1] && arr[1].kind) + ' writeRelays=' + _wsWriteRelays.length);
816 var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
817 for (var k = 0; k < _wsWriteRelays.length; k++) {
818 var ws3 = _wsEnsure(_wsWriteRelays[k]);
819 if (ws3 && ws3.readyState === WebSocket.OPEN) { _diag('SEND ' + _wsWriteRelays[k]); ws3.send(data); }
820 else if (ws3) { var u3 = _wsWriteRelays[k]; _diag('QUEUE ' + u3 + ' state=' + ws3.readyState); if (!_wsPendingPub[u3]) _wsPendingPub[u3] = []; _wsPendingPub[u3].push(data); }
821 else { _diag('FAIL no WS ' + _wsWriteRelays[k]); }
822 }
823 } else if (cmd === "CLEAR_KEY") {
824 for (var sid3 in _wsSubs) _wsCloseSub(sid3);
825 for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
826 _wsConns = {};
827 _wsWriteRelays = [];
828 _wsPendingPub = {};
829 }
830 }
831
832 // ========================================================================
833
834 // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
835 var _swSent = [];
836
837 export function PostToSW(msg) {
838 if (!_hasSW) {
839 _wsHandleMsg('' + msg);
840 return;
841 }
842 var s = '' + msg;
843 if (_swSent.length > 500) _swSent = _swSent.slice(-100);
844 _swSent.push(s);
845 if (navigator.serviceWorker.controller) {
846 navigator.serviceWorker.controller.postMessage(s);
847 } else {
848 _swQueue.push(s);
849 }
850 }
851
852 function _sendToSW() {
853 if (!navigator.serviceWorker.controller) return;
854 // Prefer queued messages (never sent). If queue is empty, replay all sent
855 // messages (they went to an old SW that was replaced).
856 var msgs;
857 if (_swQueue.length > 0) {
858 msgs = _swQueue;
859 _swQueue = [];
860 } else if (_swSent.length > 0) {
861 msgs = _swSent.slice();
862 _swSent = [];
863 } else {
864 return;
865 }
866 for (var i = 0; i < msgs.length; i++) {
867 navigator.serviceWorker.controller.postMessage(msgs[i]);
868 }
869 }
870
871 if (_hasSW) {
872 navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
873 navigator.serviceWorker.ready.then(function(reg) {
874 if (!navigator.serviceWorker.controller && reg.active) {
875 reg.active.postMessage('CLAIM');
876 }
877 setTimeout(_sendToSW, 100);
878 });
879 }
880
881 let _swMessageListener = null;
882
883 export function OnSWMessage(fn) {
884 _swMessageHandler = fn;
885 if (_hasSW) {
886 if (_swMessageListener) {
887 navigator.serviceWorker.removeEventListener('message', _swMessageListener);
888 }
889 _swMessageListener = function(e) {
890 var d = e.data;
891 if (typeof d !== 'string') {
892 // Backward compat: old SW may send Slice objects via structured clone.
893 if (d && d.$array && typeof d.$length === 'number') {
894 var buf = new Uint8Array(d.$length);
895 var off = d.$offset || 0;
896 for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
897 d = new TextDecoder().decode(buf);
898 } else {
899 return; // Not a string and not a Slice — skip.
900 }
901 }
902 if (_swMessageHandler) _swMessageHandler(d);
903 };
904 navigator.serviceWorker.addEventListener('message', _swMessageListener);
905 }
906 }
907
908 export function ReadClipboard(fn) {
909 if (navigator.clipboard && navigator.clipboard.readText) {
910 navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
911 var r = prompt('Paste nsec:');
912 fn(r || '');
913 });
914 } else {
915 var r = prompt('Paste nsec:');
916 fn(r || '');
917 }
918 }
919
920 export function WriteClipboard(text, fn) {
921 const s = '' + text;
922 if (navigator.clipboard && navigator.clipboard.writeText) {
923 navigator.clipboard.writeText(s).then(function() { fn(true); }).catch(function() { fn(false); });
924 return;
925 }
926 try {
927 const ta = document.createElement('textarea');
928 ta.value = s;
929 ta.style.position = 'fixed';
930 ta.style.opacity = '0';
931 document.body.appendChild(ta);
932 ta.focus();
933 ta.select();
934 const ok = document.execCommand('copy');
935 document.body.removeChild(ta);
936 fn(!!ok);
937 } catch (e) {
938 fn(false);
939 }
940 }
941
942 // WritePrimarySelection writes text to the X11 primary selection (middle-click
943 // paste buffer). Creates an off-screen textarea, selects it, and relies on the
944 // browser propagating the selection to the X server. No-op on non-X11 platforms.
945 export function WritePrimarySelection(text) {
946 const s = '' + text;
947 try {
948 const ta = document.createElement('textarea');
949 ta.value = s;
950 ta.style.position = 'fixed';
951 ta.style.top = '-9999px';
952 ta.style.left = '-9999px';
953 ta.style.opacity = '0';
954 document.body.appendChild(ta);
955 ta.focus();
956 ta.select();
957 document.execCommand('copy');
958 document.body.removeChild(ta);
959 } catch (_) {}
960 }
961
962 // CopyImageToClipboard copies an image element's pixel data to the clipboard
963 // as image/png via ClipboardItem. Requires a secure context (HTTPS or localhost).
964 // fn is called with true on success, false on failure or if API unavailable.
965 export function CopyImageToClipboard(elId, fn) {
966 const img = _elements.get(elId);
967 if (!img || !navigator.clipboard || !window.ClipboardItem) {
968 fn(false);
969 return;
970 }
971 try {
972 const canvas = document.createElement('canvas');
973 canvas.width = img.naturalWidth || img.width || 1;
974 canvas.height = img.naturalHeight || img.height || 1;
975 const ctx = canvas.getContext('2d');
976 ctx.drawImage(img, 0, 0);
977 canvas.toBlob(function(blob) {
978 if (!blob) { fn(false); return; }
979 navigator.clipboard.write([new ClipboardItem({'image/png': blob})])
980 .then(function() { fn(true); })
981 .catch(function() { fn(false); });
982 }, 'image/png');
983 } catch (_) {
984 fn(false);
985 }
986 }
987
988 // Fetch relay NIP-11 info.
989 export function FetchRelayInfo(url, fn) {
990 fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
991 .then(function(r) { return r.text(); })
992 .then(function(t) { if (fn) fn(t); })
993 .catch(function() { if (fn) fn(''); });
994 }
995
996 // History / location.
997 export function PushState(path) {
998 if (typeof history !== 'undefined') history.pushState(null, '', path);
999 }
1000
1001 export function ReplaceState(path) {
1002 if (typeof history !== 'undefined') history.replaceState(null, '', path);
1003 }
1004
1005 export function Back() {
1006 if (typeof history !== 'undefined') history.back();
1007 }
1008
1009 export function LocationReload() {
1010 if (typeof location !== 'undefined') location.reload();
1011 }
1012
1013 export function LocationAssign(url) {
1014 if (typeof location !== 'undefined') location.assign(url);
1015 }
1016
1017 export function HardRefresh() {
1018 var p = [];
1019 if (typeof caches !== 'undefined') {
1020 p.push(caches.keys().then(function(keys) {
1021 return Promise.all(keys.map(function(k) { return caches.delete(k); }));
1022 }));
1023 }
1024 if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
1025 p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
1026 regs.forEach(function(r) { r.unregister(); });
1027 }));
1028 }
1029 Promise.all(p).then(function() { location.reload(); });
1030 }
1031
1032 export function ClearStoragePrefix(prefix) {
1033 if (typeof localStorage === 'undefined') return;
1034 var keys = [];
1035 for (var i = 0; i < localStorage.length; i++) {
1036 var k = localStorage.key(i);
1037 if (k && k.indexOf(prefix) === 0) keys.push(k);
1038 }
1039 for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
1040 }
1041
1042 export function TimezoneOffsetSeconds() {
1043 // getTimezoneOffset() returns minutes, positive west of UTC.
1044 // We return seconds to ADD to UTC to get local time.
1045 return -(new Date().getTimezoneOffset()) * 60;
1046 }
1047
1048 export function GetPath() {
1049 if (typeof location !== 'undefined') return location.pathname;
1050 return '/';
1051 }
1052
1053 export function GetHash() {
1054 if (typeof location !== 'undefined') return location.hash;
1055 return '';
1056 }
1057
1058 export function Hostname() {
1059 if (typeof location !== 'undefined') return location.hostname;
1060 return '';
1061 }
1062
1063 export function Port() {
1064 if (typeof location !== 'undefined') return location.port;
1065 return '';
1066 }
1067
1068 export function UserAgent() {
1069 if (typeof navigator !== 'undefined') return navigator.userAgent;
1070 return '';
1071 }
1072
1073 let _popStateListener = null;
1074
1075 export function OnPopState(fn) {
1076 if (typeof window !== 'undefined') {
1077 if (_popStateListener) {
1078 window.removeEventListener('popstate', _popStateListener);
1079 }
1080 _popStateListener = function() {
1081 if (fn) fn(location.pathname);
1082 };
1083 window.addEventListener('popstate', _popStateListener);
1084 }
1085 }
1086
1087 export function InterceptInternalLinks(fn) {
1088 if (typeof document === 'undefined') return;
1089 document.addEventListener('click', function(e) {
1090 let el = e.target;
1091 while (el && el.tagName !== 'A') el = el.parentElement;
1092 if (!el) return;
1093 const href = el.getAttribute('href');
1094 if (!href || href[0] !== '/') return;
1095 e.preventDefault();
1096 history.pushState(null, '', href);
1097 if (fn) fn(href);
1098 });
1099 }
1100
1101 // DownloadText triggers a file download with the given text content.
1102 export function DownloadText(filename, content, mimeType) {
1103 const blob = new Blob([content], { type: mimeType || 'text/plain' });
1104 const url = URL.createObjectURL(blob);
1105 const a = document.createElement('a');
1106 a.href = url;
1107 a.download = filename;
1108 document.body.appendChild(a);
1109 a.click();
1110 setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
1111 }
1112
1113 // PickFileText opens a file picker and reads the selected file as text.
1114 export function PickFileText(accept, fn) {
1115 const input = document.createElement('input');
1116 input.type = 'file';
1117 if (accept) input.accept = accept;
1118 input.style.display = 'none';
1119 input.addEventListener('change', function() {
1120 if (!input.files || !input.files[0]) { fn(''); return; }
1121 const reader = new FileReader();
1122 reader.onload = function() { fn(reader.result || ''); };
1123 reader.onerror = function() { fn(''); };
1124 reader.readAsText(input.files[0]);
1125 });
1126 document.body.appendChild(input);
1127 input.click();
1128 // Clean up after a delay (even if user cancels).
1129 setTimeout(function() { input.remove(); }, 60000);
1130 }
1131
1132 // NowSeconds returns current Unix time in seconds.
1133 export function NowSeconds() {
1134 return BigInt(Math.floor(Date.now() / 1000));
1135 }
1136
1137 // OnPullRefresh registers a handler that fires when the user pulls to refresh.
1138 // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
1139 export function OnPullRefresh(elId, indicatorId, fn) {
1140 const el = _elements.get(elId);
1141 const ind = _elements.get(indicatorId);
1142 if (!el) return;
1143
1144 var pullAccum = 0;
1145 var pullTimer = 0;
1146 var threshold = 200;
1147 var resetMs = 400;
1148
1149 el.addEventListener('wheel', function(e) {
1150 if (el.scrollTop > 0 || e.deltaY >= 0) {
1151 pullAccum = 0;
1152 if (ind) ind.style.display = 'none';
1153 return;
1154 }
1155 pullAccum += Math.abs(e.deltaY);
1156 clearTimeout(pullTimer);
1157 pullTimer = setTimeout(function() {
1158 pullAccum = 0;
1159 if (ind) ind.style.display = 'none';
1160 }, resetMs);
1161 if (ind && pullAccum > 40) ind.style.display = 'flex';
1162 if (pullAccum >= threshold) {
1163 pullAccum = 0;
1164 fn();
1165 }
1166 }, { passive: true });
1167
1168 var touchStartY = 0;
1169 el.addEventListener('touchstart', function(e) {
1170 if (el.scrollTop === 0 && e.touches.length === 1) {
1171 touchStartY = e.touches[0].clientY;
1172 }
1173 }, { passive: true });
1174 el.addEventListener('touchmove', function(e) {
1175 if (el.scrollTop > 0 || touchStartY === 0) return;
1176 var dy = e.touches[0].clientY - touchStartY;
1177 if (dy > 20 && ind) ind.style.display = 'flex';
1178 if (dy > 80) {
1179 touchStartY = 0;
1180 fn();
1181 }
1182 }, { passive: true });
1183 el.addEventListener('touchend', function() {
1184 touchStartY = 0;
1185 if (pullAccum < threshold && ind) ind.style.display = 'none';
1186 }, { passive: true });
1187 }
1188
1189 // PickFileBase64: open file picker, read as base64, call fn(base64, mime).
1190 export function PickFileBase64(accept, fn) {
1191 const input = document.createElement('input');
1192 input.type = 'file';
1193 if (accept) input.accept = accept;
1194 input.style.display = 'none';
1195 input.addEventListener('change', function() {
1196 if (!input.files || !input.files[0]) { fn('', ''); return; }
1197 const file = input.files[0];
1198 const mime = file.type || 'application/octet-stream';
1199 const reader = new FileReader();
1200 reader.onload = function() {
1201 const b64 = reader.result.split(',')[1] || '';
1202 fn(b64, mime);
1203 };
1204 reader.onerror = function() { fn('', ''); };
1205 reader.readAsDataURL(file);
1206 input.remove();
1207 });
1208 document.body.appendChild(input);
1209 input.click();
1210 setTimeout(function() { input.remove(); }, 120000);
1211 }
1212
1213 // OnPasteImage: listen for paste events with image data on an element.
1214 export function OnPasteImage(elId, fn) {
1215 const el = _elements.get(elId);
1216 if (!el) return;
1217 el.addEventListener('paste', function(e) {
1218 const items = e.clipboardData && e.clipboardData.items;
1219 if (!items) return;
1220 for (let i = 0; i < items.length; i++) {
1221 if (items[i].type.indexOf('image/') === 0) {
1222 e.preventDefault();
1223 const mime = items[i].type;
1224 const blob = items[i].getAsFile();
1225 if (!blob) continue;
1226 const reader = new FileReader();
1227 reader.onload = function() {
1228 const b64 = reader.result.split(',')[1] || '';
1229 fn(b64, mime);
1230 };
1231 reader.readAsDataURL(blob);
1232 return;
1233 }
1234 }
1235 });
1236 }
1237
1238 // OnDropImage: listen for drop events with image files on an element.
1239 export function OnDropImage(elId, fn) {
1240 const el = _elements.get(elId);
1241 if (!el) return;
1242 el.addEventListener('dragenter', function(e) { e.preventDefault(); e.stopPropagation(); });
1243 el.addEventListener('dragover', function(e) { e.preventDefault(); e.stopPropagation(); });
1244 el.addEventListener('drop', function(e) {
1245 e.preventDefault();
1246 e.stopPropagation();
1247 const files = e.dataTransfer && e.dataTransfer.files;
1248 if (!files || files.length === 0) return;
1249 for (let i = 0; i < files.length; i++) {
1250 const f = files[i];
1251 const mime = f.type || 'application/octet-stream';
1252 if (mime.indexOf('image/') !== 0) continue;
1253 const reader = new FileReader();
1254 reader.onload = function() {
1255 const b64 = reader.result.split(',')[1] || '';
1256 fn(b64, mime);
1257 };
1258 reader.readAsDataURL(f);
1259 }
1260 });
1261 }
1262
1263 // FetchPutBlobBase64: PUT binary data (from base64) to a URL with auth header.
1264 export function FetchPutBlobBase64(url, base64data, contentType, authHeader, fn) {
1265 try {
1266 const raw = atob(base64data);
1267 const arr = new Uint8Array(raw.length);
1268 for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
1269 const headers = { 'Content-Type': contentType || 'application/octet-stream' };
1270 if (authHeader) headers['Authorization'] = authHeader;
1271 fetch(url, { method: 'PUT', headers: headers, body: arr })
1272 .then(function(r) { return r.text(); })
1273 .then(function(t) { if (fn) fn(t); })
1274 .catch(function() { if (fn) fn(''); });
1275 } catch(e) {
1276 if (fn) fn('');
1277 }
1278 }
1279
1280 // Get raw element (for advanced use within JS runtime only).
1281 export function getRawElement(id) {
1282 return _elements.get(id);
1283 }
1284