rapier-embed 1.2.33 → 1.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/embed.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker} = (() => {
2
+ const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase} = (() => {
3
3
  const FEATURES = Object.freeze(['draw', 'paint', 'notes', 'readAloud', 'share', 'find']);
4
4
  const LIMITS = Object.freeze({documentBytes: 64 * 1024 * 1024, pictureBytes: 16 * 1024 * 1024});
5
5
  const ASSET_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/jxl', 'image/svg+xml']);
@@ -238,22 +238,53 @@ function createAssetBroker({current, send}) {
238
238
  function lookup(url) { scope(); return retained.get(url)?.asset || null; }
239
239
  return Object.freeze({request, answer, cancel, lookup});
240
240
  }
241
- return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker};
241
+
242
+ function pictureBase(value) {
243
+ if (value === undefined || value === null) return null;
244
+ let base;
245
+ try {
246
+ if (typeof value !== 'string' || !value || value.length > 4096 || /[\s\u0000-\u001f\u007f<>\\]/u.test(value)) throw 0;
247
+ base = new URL(value);
248
+ if (base.username || base.password || !(base.protocol === 'https:' || base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw 0;
249
+ } catch (_) { throw refusal('invalid_base_url', 'baseUrl is an absolute https address, or http on localhost.'); }
250
+ const vouched = new Set();
251
+ return Object.assign(src => {
252
+ const text = String(src || '').trim();
253
+ if (!text || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)) return null;
254
+ try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
255
+ }, {vouched});
256
+ }
257
+ return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
242
258
  })();
243
259
  const BUILDS = Object.freeze({full: 'https://rapier.website/embed/rapier.html', reader: 'https://rapier.website/embed/rapier-reader.html'});
260
+ const PACKAGE_VERSION = '1.2.40';
261
+ const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
262
+ 'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
263
+ const READER_FEATURES = ['find', 'readAloud', 'share'];
244
264
  const DEFAULT_SRC = BUILDS.full;
245
265
  const revision = value => Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && value.length > 0 && value.length <= 256;
246
266
  const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
247
267
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
248
268
  const themeValue = value => ['light', 'dark', 'system'].includes(value);
249
269
  const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
270
+ const autoLive = new Map();
271
+ const fnv = text => { let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36); };
272
+ function autoIdentity(options) {
273
+ const load = options.load;
274
+ if (!options.save || !load || typeof load !== 'object' || typeof load.content !== 'string') return null;
275
+ const base = fnv(location.origin + location.pathname) + '-' + fnv(String(load.filename ?? '') + '|' + load.content.length + '|' + load.content.slice(0, 2048));
276
+ let at = 0;
277
+ while (autoLive.get(base + '-' + at)?.()) at++;
278
+ return base + '-' + at;
279
+ }
250
280
  function deferred() {
251
281
  let resolve, reject;
252
282
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
253
283
  promise.catch(() => {});
254
284
  return {promise, resolve, reject};
255
285
  }
256
- const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload});
286
+ const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload},
287
+ revision(payload?.currentRevision) ? {currentRevision: payload.currentRevision} : {});
257
288
 
258
289
  function conflict(currentRevision) {
259
290
  if (!revision(currentRevision)) throw new TypeError('A conflict needs the current revision');
@@ -270,23 +301,43 @@ function readerStyle(value) {
270
301
  return {...(value.css !== undefined ? {css: value.css} : {}), ...(value.fonts !== undefined ? {fonts: value.fonts.map(row => ({...row}))} : {})};
271
302
  }
272
303
 
304
+ const HOSTED_ORIGIN = new URL(BUILDS.full).origin;
305
+ function framingProblem(host, source, sandbox, noun) {
306
+ const here = host.location, local = ['localhost', '127.0.0.1', '[::1]'].includes(here.hostname);
307
+ if (here.protocol !== 'https:' && here.protocol !== 'http:')
308
+ return {code: 'host_scheme_unsupported', reason: 'The ' + noun + ' cannot be framed by a ' + here.protocol + ' page: it needs an https page, or http on localhost. Serve your app over one of those.'};
309
+ if (source.origin === HOSTED_ORIGIN && here.protocol === 'http:' && !local)
310
+ return {code: 'host_not_secure', reason: 'The hosted ' + noun + ' can be framed by https pages and by http on localhost only. Serve your app over https, or serve a copy of the page yourself and set src.'};
311
+ if (source.origin === HOSTED_ORIGIN && host.crossOriginIsolated === true)
312
+ return {code: 'host_cross_origin_isolated', reason: 'This page is cross-origin isolated (COEP), which blocks the hosted ' + noun + '. Serve a copy of the page yourself with the Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy headers and set src, or drop COEP.'};
313
+ if (sandbox) for (const token of ['allow-scripts', 'allow-same-origin']) if (!sandbox.contains(token))
314
+ return {code: 'sandbox_token_missing', reason: 'The iframe you passed has a sandbox without ' + token + ', which the ' + noun + ' needs. Add the token, or pass a container and let mount create the iframe.'};
315
+ return null;
316
+ }
317
+
273
318
  function mount(target, options = {}) {
274
319
  const document = target?.ownerDocument;
275
320
  if (!document) throw new TypeError('Mount needs an iframe or a container element');
321
+ const unknown = Object.keys(options).filter(key => !OPTIONS.includes(key));
322
+ if (unknown.length) throw Object.assign(new TypeError('Unknown option: ' + unknown.join(', ') + '. Mount takes ' + OPTIONS.join(', ')), {code: 'option_unknown'});
276
323
  const build = options.build === undefined || options.build === 'document' ? 'full' : options.build;
277
324
  if (!Object.hasOwn(BUILDS, build)) throw new TypeError('build must be full or reader');
278
- const reader = build === 'reader';
325
+ const reader = build === 'reader', noun = reader ? 'reader' : 'editor';
279
326
  if (reader && ['save', 'compare', 'onClose', 'agent', 'agentName', 'assets'].some(key => options[key] !== undefined && options[key] !== false))
280
327
  throw new TypeError('The reader takes load, onState, theme, settings, style and plugins');
281
328
  if (!reader && options.style !== undefined) throw new TypeError('style restyles the reader build');
282
329
  if (options.style !== undefined && options.load === undefined) throw new TypeError('Reader styling requires load');
283
330
  if (options.plugins !== undefined && (typeof options.plugins !== 'string' || !options.plugins || options.plugins.length > 2048 || /[\u0000-\u001f\u007f]/.test(options.plugins)))
284
331
  throw new TypeError('plugins is the address of the directory that holds the plug-in files');
332
+ if (options.approvePlugins !== undefined && typeof options.approvePlugins !== 'boolean') throw new TypeError('approvePlugins is true or false');
285
333
  let currentStyle = options.style === undefined ? undefined : readerStyle(options.style);
286
334
  const host = document.defaultView, iframe = target.tagName === 'IFRAME' ? target : document.createElement('iframe');
287
335
  const source = new URL(options.src || BUILDS[build], host.location.href);
288
336
  if (source.username || source.password || !(source.protocol === 'https:' || source.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(source.hostname)))
289
337
  throw new TypeError('The editor needs HTTPS, or HTTP on localhost');
338
+ const page = source.pathname.split('/').pop();
339
+ if (reader ? page === 'rapier.html' : page === 'rapier-reader.html')
340
+ throw Object.assign(new TypeError('build ' + build + ' cannot frame ' + page + '; set build: \'' + (reader ? 'full' : 'reader') + '\' or src to ' + (reader ? 'rapier-reader.html' : 'rapier.html')), {code: 'build_mismatch'});
290
341
  source.searchParams.set('embed', '1');
291
342
  if (options.plugins !== undefined) {
292
343
  let directory;
@@ -297,7 +348,9 @@ function mount(target, options = {}) {
297
348
  throw new TypeError('plugins must be on ' + source.origin + ', the origin of the page it configures: the page reads plug-ins only from its own origin. Serve the page and its plug-ins together (set src), or leave plugins out');
298
349
  source.searchParams.set('plugins', directory.href);
299
350
  }
300
- const origin = source.origin, sessionId = options.sessionId ?? mint('session'), documentId = options.documentId ?? mint('document');
351
+ if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
352
+ const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options) : null;
353
+ const sessionId = options.sessionId ?? (auto ? 'page-' + auto : mint('session')), documentId = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
301
354
  if (!identifier(sessionId) || !identifier(documentId)) throw new TypeError('Session and document IDs must be nonempty strings of at most 256 code units');
302
355
  const agentName = options.agentName;
303
356
  if (agentName !== undefined && (typeof agentName !== 'string' || !agentName.trim() || agentName.length > 96 || /[\u0000-\u001f\u007f]/.test(agentName))) throw new TypeError('agentName must be a host-given name of at most 96 characters');
@@ -305,7 +358,11 @@ function mount(target, options = {}) {
305
358
  if (options.save !== undefined && typeof options.save !== 'function') throw new TypeError('save must be a durable storage callback');
306
359
  if (options.assets !== undefined && typeof options.assets !== 'function') throw new TypeError('assets must be a picture storage callback');
307
360
  if (options.openFile !== undefined && typeof options.openFile !== 'function') throw new TypeError('openFile must choose a File or null');
361
+ if (options.timeout !== undefined && !(Number.isSafeInteger(options.timeout) && options.timeout > 0)) throw new TypeError('timeout is a whole number of milliseconds');
362
+ const bootTimeout = options.timeout ?? 15000;
308
363
  const settings = snapshotSettings(options.settings), policy = normalizeSettings(settings), storeAsset = options.assets;
364
+ const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
365
+ if (unread.length) throw Object.assign(new TypeError('The reader has no ' + unread.join(', ') + '; its features are ' + READER_FEATURES.join(', ')), {code: 'settings_feature_unavailable'});
309
366
  const assetAnswers = new Map();
310
367
  let assetPending = '', assetAbort = new AbortController();
311
368
  const chooseFile = options.openFile || (({signal}) => pickDeviceFile(document, signal)), openAnswers = new Map();
@@ -319,7 +376,8 @@ function mount(target, options = {}) {
319
376
  throw new TypeError('Agent access needs both load and save');
320
377
  const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
321
378
  let port = null, posted = false, connected = false, ended = false, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
322
- let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot;
379
+ let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, frameFacts = {};
380
+ let held = null;
323
381
  const ready = deferred();
324
382
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
325
383
  const fail = error => { emit('error', error); options.onError?.(error); };
@@ -328,7 +386,7 @@ function mount(target, options = {}) {
328
386
  waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
329
387
  };
330
388
  function send(type, payload, baseRevision, quiet = false) {
331
- if (ended || !connected || !port) return Promise.reject(new Error('The editor connection has ended or is not connected'));
389
+ if (ended || !connected || !port) return Promise.reject(new Error('The ' + noun + ' connection has ended or is not connected'));
332
390
  if (type === 'load') cancelOpen('open_stale');
333
391
  const requestId = 'host-' + ++seq;
334
392
  const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload !== undefined ? {payload} : {})};
@@ -339,6 +397,7 @@ function mount(target, options = {}) {
339
397
  return pending.promise;
340
398
  }
341
399
  function requestCapture() {
400
+ if (activeSave) activeSave.commandId = 'host-' + (seq + 1);
342
401
  send('save', undefined, undefined, true).catch(error => { activeSave?.reject(error); activeSave = null; });
343
402
  }
344
403
  async function answerSave(message, channel) {
@@ -379,7 +438,7 @@ function mount(target, options = {}) {
379
438
  if (ended || channel !== port) return;
380
439
  channel.postMessage(reply);
381
440
  if (reply.type === 'save-ack') {
382
- lastSaved = reply.payload;
441
+ lastSaved = reply.payload; held = {revision: reply.payload.revision, content: payload.content, filename: payload.filename};
383
442
  if (activeSave) activeSave.acknowledged = true;
384
443
  } else if (activeSave) { activeSave.reject(refused(reply.payload)); activeSave = null; }
385
444
  }
@@ -495,7 +554,9 @@ function mount(target, options = {}) {
495
554
  }
496
555
  if (type === 'connected') {
497
556
  if (connected) return;
498
- connected = true; connectedWait.resolve(); emit('connected', payload); return;
557
+ clearTimeout(bootTimer);
558
+ connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
559
+ connectedWait.resolve(); emit('connected', payload); return;
499
560
  }
500
561
  if (!connected) return;
501
562
  if (type === 'open-request' || type === 'open-cancel') { answerOpen(message, channel); return; }
@@ -505,6 +566,10 @@ function mount(target, options = {}) {
505
566
  if (!['loaded', 'dirty', 'saving', 'closing', 'readOnly'].every(key => typeof payload[key] === 'boolean')
506
567
  || Object.keys(payload).some(key => !['loaded', 'dirty', 'saving', 'closing', 'readOnly', 'filename', 'docKind'].includes(key))) return;
507
568
  readOnly = payload.readOnly; emit('state', payload); options.onState?.(payload);
569
+ if (activeSave && !activeSave.acknowledged && activeSave.commandId && message.requestId === activeSave.commandId && payload.loaded && !payload.dirty && !payload.saving && revision(message.baseRevision)) {
570
+ activeSave.resolve({revision: message.baseRevision}); activeSave = null;
571
+ return;
572
+ }
508
573
  if (activeSave?.acknowledged && !payload.saving && message.baseRevision === lastSaved?.revision) {
509
574
  if (payload.dirty) { activeSave.acknowledged = false; requestCapture(); }
510
575
  else { activeSave.resolve(lastSaved); activeSave = null; }
@@ -530,7 +595,9 @@ function mount(target, options = {}) {
530
595
  }
531
596
  if (pending && ['load-ack', 'load-nack', 'compare-ack', 'compare-nack'].includes(type)) {
532
597
  waiting.delete(requestId);
533
- if (type === 'load-ack') readOnly = payload.readOnly === true;
598
+ if (type === 'load-ack') {
599
+ readOnly = payload.readOnly === true;
600
+ }
534
601
  if (type.endsWith('-nack')) pending.reject(refused(payload)); else pending.resolve(payload);
535
602
  }
536
603
  }
@@ -541,6 +608,7 @@ function mount(target, options = {}) {
541
608
  const value = typeof options.load === 'function' ? await options.load() : options.load;
542
609
  if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw new TypeError('load needs content and its revision');
543
610
  await send('load', value, null);
611
+ held = {revision: value.revision, content: value.content, filename: value.filename};
544
612
  }
545
613
  ready.resolve(handle);
546
614
  });
@@ -553,24 +621,42 @@ function mount(target, options = {}) {
553
621
  port.onmessage = event => onPortMessage(event, channel.port1);
554
622
  port.onmessageerror = () => {
555
623
  if (channel.port1 !== port || ended) return;
556
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The editor connection was lost'));
624
+ cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The ' + noun + ' connection was lost'));
557
625
  };
558
626
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
559
627
  catch (error) { port.close(); port = null; posted = false; fail(error); }
560
628
  }
629
+ function bootFailed(code, reason) {
630
+ clearTimeout(bootTimer);
631
+ if (connected || ended) return;
632
+ connectedWait.reject(refusal(code, reason));
633
+ }
634
+ const connectTimeoutReason = () => 'The ' + noun + ' did not connect within ' + bootTimeout / 1000 + ' seconds. A page that does not allow this parent (frame-ancestors) is blocked and never connects; the hosted pages allow https, localhost and 127.0.0.1 parents.';
561
635
  function onLoad() {
562
636
  if (sawLoad && posted) {
563
637
  cancelOpen('open_disconnected');
564
- rejectPending(new Error('The editor reconnected')); port?.close();
638
+ rejectPending(new Error('The ' + noun + ' reconnected')); port?.close();
565
639
  assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
566
640
  connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
567
641
  }
568
642
  sawLoad = true;
643
+ clearTimeout(bootTimer);
644
+ if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
569
645
  connect();
570
646
  }
647
+ function focusBeside(forward) {
648
+ const order = forward ? host.Node.DOCUMENT_POSITION_FOLLOWING : host.Node.DOCUMENT_POSITION_PRECEDING;
649
+ const controls = [...document.querySelectorAll('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]')]
650
+ .filter(element => element !== iframe && element.tabIndex >= 0 && !element.disabled && !element.closest('[inert]') && element.getClientRects().length &&
651
+ iframe.compareDocumentPosition(element) & order);
652
+ const next = forward ? controls[0] : controls.at(-1);
653
+ if (next) next.focus(); else iframe.blur();
654
+ }
571
655
  function onWindowMessage(event) {
572
656
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
573
657
  if (event.data?.type === 'rapier-ready') connect();
658
+ else if (event.data?.type === 'rapier-boot-failed') bootFailed('boot_failed', 'The ' + noun + ' could not start');
659
+ else if (event.data?.type === 'rapier-focus-leave' && connected) focusBeside(event.data.backward !== true);
574
660
  else if (event.data?.type === 'close-ready' && event.data.sessionId === sessionId && event.data.documentId === documentId && options.onClose) {
575
661
  activeClose?.resolve(event.data.payload); activeClose = null; emit('closed', event.data.payload);
576
662
  }
@@ -578,17 +664,30 @@ function mount(target, options = {}) {
578
664
  const handle = Object.freeze({
579
665
  iframe, ready: ready.promise,
580
666
  get connected() { return connected && !ended; },
581
- async load(content, {filename, revision: version, readOnly, title} = {}) {
667
+ get version() { return frameFacts.version ?? null; },
668
+ get contract() { return frameFacts.contract ?? null; },
669
+ async load(content, options = {}) {
670
+ if (record(content)) { options = content; content = options.content; }
671
+ if (typeof content !== 'string') throw Object.assign(new TypeError('load needs the document text as a string'), {code: 'invalid_payload'});
672
+ let {filename, revision: version, readOnly, title, baseUrl} = options;
582
673
  cancelOpen('open_stale');
583
674
  await boot;
584
- return send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {})}, null);
675
+ if (version === undefined) version = typeof held?.revision === 'number' ? held.revision + 1 : 1;
676
+ const sameDocument = held && (filename === undefined || held.filename === undefined || filename === held.filename);
677
+ if (sameDocument && typeof version === 'number' && typeof held.revision === 'number' && version < held.revision)
678
+ throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
679
+ if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
680
+ throw Object.assign(new Error('Revision ' + version + ' already names other content'), {code: 'revision_conflict', currentRevision: held.revision});
681
+ const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null);
682
+ held = {revision: version, content, filename};
683
+ return loaded;
585
684
  },
586
685
  async save() {
587
686
  await boot;
588
687
  if (!options.save) throw new Error('No save callback was granted');
589
- if (ended) throw new Error('The editor connection has ended');
688
+ if (ended) throw new Error('The ' + noun + ' connection has ended');
590
689
  if (readOnly) throw new Error('The document is read-only');
591
- if (!activeSave) activeSave = {...deferred(), acknowledged: false};
690
+ if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
592
691
  const promise = activeSave.promise;
593
692
  requestCapture(); return promise;
594
693
  },
@@ -611,10 +710,10 @@ function mount(target, options = {}) {
611
710
  disconnect() {
612
711
  if (ended) return;
613
712
  if (connected) send('disconnect', undefined, undefined, true).catch(() => {});
614
- ended = true; connected = false;
713
+ ended = true; connected = false; clearTimeout(bootTimer);
615
714
  cancelOpen('open_disconnected'); openAnswers.clear();
616
715
  assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
617
- const error = new Error('The editor connection has ended');
716
+ const error = new Error('The ' + noun + ' connection has ended');
618
717
  rejectPending(error); connectedWait.reject(error); ready.reject(error);
619
718
  host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
620
719
  port?.close(); port = null; listeners.clear();
@@ -626,17 +725,26 @@ function mount(target, options = {}) {
626
725
  return () => listeners.get(type)?.delete(fn);
627
726
  },
628
727
  });
728
+ if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
629
729
  prepareBoot();
630
730
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
631
731
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
632
- iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
732
+ const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
733
+ if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
734
+ const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
735
+ if (blocked) {
736
+ if (iframe !== target) target.append(iframe);
737
+ queueMicrotask(() => bootFailed(blocked.code, blocked.reason));
738
+ return handle;
739
+ }
633
740
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
634
741
  iframe.src = source.href;
635
742
  if (iframe !== target) target.append(iframe);
743
+ bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
636
744
  return handle;
637
745
  }
638
746
 
639
- export const Rapier = Object.freeze({mount, conflict});
747
+ export const Rapier = Object.freeze({mount, conflict, version: PACKAGE_VERSION, contract: 1});
640
748
 
641
749
  const forms = new WeakMap();
642
750
  function joinForm(field, form) {
@@ -645,8 +753,8 @@ function joinForm(field, form) {
645
753
  if (!group) {
646
754
  group = {fields: new Set(), pending: false, releasing: false};
647
755
  group.submit = async event => {
648
- if (group.releasing) return;
649
- event.preventDefault();
756
+ if (event.target !== form || group.releasing) return;
757
+ event.preventDefault(); event.stopImmediatePropagation();
650
758
  if (group.pending) return;
651
759
  group.pending = true;
652
760
  const submitter = event.submitter;
@@ -662,12 +770,12 @@ function joinForm(field, form) {
662
770
  } catch (error) { if (current()) for (const {field} of captured) field._error(error); }
663
771
  finally { group.pending = false; group.releasing = false; }
664
772
  };
665
- form.addEventListener('submit', group.submit, true); forms.set(form, group);
773
+ group.view = form.ownerDocument.defaultView; group.view.addEventListener('submit', group.submit, true); forms.set(form, group);
666
774
  }
667
775
  group.fields.add(field);
668
776
  return () => {
669
777
  group.fields.delete(field);
670
- if (!group.fields.size) { form.removeEventListener('submit', group.submit, true); forms.delete(form); }
778
+ if (!group.fields.size) { group.view.removeEventListener('submit', group.submit, true); forms.delete(form); }
671
779
  };
672
780
  }
673
781
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.2.33",
3
+ "version": "1.2.40",
4
4
  "description": "Embed the Rapier Markdown editor or its lightweight reader (about 360 kB) in your web app. Runs offline from your own copy, saves to your storage, and takes your CSS and fonts. Your app's AI agent edits directly over WebMCP under the Will. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",