rapier-embed 1.2.40 → 1.2.42

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,10 +1,11 @@
1
1
  // SPDX-License-Identifier: MIT
2
- const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase} = (() => {
2
+ const {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, 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']);
6
6
  const ACCENTS = Object.freeze(['Teal', 'Blue', 'Amber', 'Green', 'Red', 'Purple', 'Pink', 'Gray']);
7
7
  const refusal = (code, reason = code) => Object.assign(new Error(reason), {code});
8
+ const coded = (code, Type, message) => Object.assign(new Type(message), {code});
8
9
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value) &&
9
10
  (Object.getPrototypeOf(value) === null || Object.getPrototypeOf(Object.getPrototypeOf(value)) === null);
10
11
  const only = (value, keys) => Object.keys(value).every(key => keys.includes(key));
@@ -254,20 +255,20 @@ function pictureBase(value) {
254
255
  try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
255
256
  }, {vouched});
256
257
  }
257
- return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
258
+ return {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
258
259
  })();
259
260
  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 PACKAGE_VERSION = '1.2.42';
261
262
  const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
262
263
  'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
263
264
  const READER_FEATURES = ['find', 'readAloud', 'share'];
264
265
  const DEFAULT_SRC = BUILDS.full;
265
266
  const revision = value => Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && value.length > 0 && value.length <= 256;
266
- const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
267
+ const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256 && value.isWellFormed();
267
268
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
268
269
  const themeValue = value => ['light', 'dark', 'system'].includes(value);
269
270
  const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
270
- const autoLive = new Map();
271
+ const autoLive = new Map(), slotLive = new Map();
271
272
  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
273
  function autoIdentity(options) {
273
274
  const load = options.load;
@@ -283,21 +284,21 @@ function deferred() {
283
284
  promise.catch(() => {});
284
285
  return {promise, resolve, reject};
285
286
  }
286
- const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload},
287
+ const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code ?? 'refused', payload},
287
288
  revision(payload?.currentRevision) ? {currentRevision: payload.currentRevision} : {});
288
289
 
289
290
  function conflict(currentRevision) {
290
- if (!revision(currentRevision)) throw new TypeError('A conflict needs the current revision');
291
+ if (!revision(currentRevision)) throw coded('invalid_revision', TypeError, 'A conflict needs the current revision');
291
292
  return Object.assign(new Error('The stored document has changed'), {code: 'conflict', currentRevision});
292
293
  }
293
294
 
294
295
  function readerStyle(value) {
295
- if (!record(value) || Object.keys(value).some(key => !['css', 'fonts'].includes(key))) throw new TypeError('style takes css and fonts');
296
- if (value.css !== undefined && typeof value.css !== 'string') throw new TypeError('style.css must be a stylesheet');
296
+ if (!record(value) || Object.keys(value).some(key => !['css', 'fonts'].includes(key))) throw coded('invalid_style', TypeError, 'style takes css and fonts');
297
+ if (value.css !== undefined && typeof value.css !== 'string') throw coded('invalid_style', TypeError, 'style.css must be a stylesheet');
297
298
  const face = row => record(row) && typeof row.family === 'string' && row.family.length > 0 &&
298
299
  (typeof row.source === 'string' || row.source instanceof ArrayBuffer || ArrayBuffer.isView(row.source)) &&
299
300
  (row.descriptors === undefined || record(row.descriptors)) && Object.keys(row).every(key => ['family', 'source', 'descriptors'].includes(key));
300
- if (value.fonts !== undefined && (!Array.isArray(value.fonts) || !value.fonts.every(face))) throw new TypeError('style.fonts takes {family, source, descriptors?} faces');
301
+ if (value.fonts !== undefined && (!Array.isArray(value.fonts) || !value.fonts.every(face))) throw coded('invalid_style', TypeError, 'style.fonts takes {family, source, descriptors?} faces');
301
302
  return {...(value.css !== undefined ? {css: value.css} : {}), ...(value.fonts !== undefined ? {fonts: value.fonts.map(row => ({...row}))} : {})};
302
303
  }
303
304
 
@@ -308,6 +309,12 @@ function framingProblem(host, source, sandbox, noun) {
308
309
  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
310
  if (source.origin === HOSTED_ORIGIN && here.protocol === 'http:' && !local)
310
311
  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.'};
312
+ if (source.origin === HOSTED_ORIGIN && host.origin === 'null')
313
+ return {code: 'host_sandboxed', reason: 'This page has an opaque origin (a sandbox without allow-same-origin), which the hosted ' + noun + ' refuses. Give the page an origin, or serve a copy of the ' + noun + ' yourself and set src.'};
314
+ if (source.origin === HOSTED_ORIGIN && [...(here.ancestorOrigins || [])].some(origin => /^http:\/\//.test(origin) && !/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::|$)/.test(origin)))
315
+ return {code: 'host_not_secure', reason: 'A page around this one is served over http, which the hosted ' + noun + ' refuses. Serve the whole page over https, or serve a copy of the ' + noun + ' yourself and set src.'};
316
+ if (source.origin === HOSTED_ORIGIN && host.navigator?.onLine === false)
317
+ return {code: 'offline', reason: 'The browser is offline, so the hosted ' + noun + ' cannot be fetched. Mount again when it is back, or serve a copy of the ' + noun + ' yourself and set src.'};
311
318
  if (source.origin === HOSTED_ORIGIN && host.crossOriginIsolated === true)
312
319
  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
320
  if (sandbox) for (const token of ['allow-scripts', 'allow-same-origin']) if (!sandbox.contains(token))
@@ -317,49 +324,55 @@ function framingProblem(host, source, sandbox, noun) {
317
324
 
318
325
  function mount(target, options = {}) {
319
326
  const document = target?.ownerDocument;
320
- if (!document) throw new TypeError('Mount needs an iframe or a container element');
327
+ if (!document || target.nodeType !== 1) throw coded('invalid_target', TypeError, 'Mount needs an iframe or a container element');
328
+ if (!record(options)) throw coded('invalid_options', TypeError, 'Mount options must be an object');
321
329
  const unknown = Object.keys(options).filter(key => !OPTIONS.includes(key));
322
330
  if (unknown.length) throw Object.assign(new TypeError('Unknown option: ' + unknown.join(', ') + '. Mount takes ' + OPTIONS.join(', ')), {code: 'option_unknown'});
323
331
  const build = options.build === undefined || options.build === 'document' ? 'full' : options.build;
324
- if (!Object.hasOwn(BUILDS, build)) throw new TypeError('build must be full or reader');
332
+ if (!Object.hasOwn(BUILDS, build)) throw coded('invalid_build', TypeError, 'build must be full or reader');
325
333
  const reader = build === 'reader', noun = reader ? 'reader' : 'editor';
326
334
  if (reader && ['save', 'compare', 'onClose', 'agent', 'agentName', 'assets'].some(key => options[key] !== undefined && options[key] !== false))
327
- throw new TypeError('The reader takes load, onState, theme, settings, style and plugins');
328
- if (!reader && options.style !== undefined) throw new TypeError('style restyles the reader build');
329
- if (options.style !== undefined && options.load === undefined) throw new TypeError('Reader styling requires load');
335
+ throw coded('reader_option_unsupported', TypeError, 'The reader takes load, onState, theme, settings, style and plugins');
336
+ if (!reader && options.style !== undefined) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
337
+ if (options.style !== undefined && options.load === undefined) throw coded('load_required', TypeError, 'Reader styling requires load');
330
338
  if (options.plugins !== undefined && (typeof options.plugins !== 'string' || !options.plugins || options.plugins.length > 2048 || /[\u0000-\u001f\u007f]/.test(options.plugins)))
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');
339
+ throw coded('invalid_plugins', TypeError, 'plugins is the address of the directory that holds the plug-in files');
340
+ if (options.approvePlugins !== undefined && typeof options.approvePlugins !== 'boolean') throw coded('invalid_option', TypeError, 'approvePlugins is true or false');
333
341
  let currentStyle = options.style === undefined ? undefined : readerStyle(options.style);
334
342
  const host = document.defaultView, iframe = target.tagName === 'IFRAME' ? target : document.createElement('iframe');
335
- const source = new URL(options.src || BUILDS[build], host.location.href);
343
+ let source;
344
+ try {
345
+ if (options.src !== undefined && typeof options.src !== 'string') throw new TypeError();
346
+ source = new URL(options.src || BUILDS[build], host.location.href);
347
+ } catch { throw coded('invalid_src', TypeError, 'src must be a page address'); }
336
348
  if (source.username || source.password || !(source.protocol === 'https:' || source.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(source.hostname)))
337
- throw new TypeError('The editor needs HTTPS, or HTTP on localhost');
349
+ throw coded('host_not_secure', TypeError, 'The editor needs HTTPS, or HTTP on localhost');
338
350
  const page = source.pathname.split('/').pop();
339
351
  if (reader ? page === 'rapier.html' : page === 'rapier-reader.html')
340
352
  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'});
341
353
  source.searchParams.set('embed', '1');
342
354
  if (options.plugins !== undefined) {
343
355
  let directory;
344
- try { directory = new URL(options.plugins, host.location.href); } catch { throw new TypeError('plugins is not an address'); }
356
+ try { directory = new URL(options.plugins, host.location.href); } catch { throw coded('invalid_plugins', TypeError, 'plugins is not an address'); }
345
357
  if (!(directory.protocol === 'https:' || directory.protocol === 'http:') || directory.username || directory.password || directory.search || directory.hash)
346
- throw new TypeError('plugins is an http or https address of a directory, with no query or fragment');
358
+ throw coded('invalid_plugins', TypeError, 'plugins is an http or https address of a directory, with no query or fragment');
347
359
  if (directory.origin !== source.origin)
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');
360
+ throw coded('invalid_plugins', 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');
349
361
  source.searchParams.set('plugins', directory.href);
350
362
  }
351
363
  if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
352
364
  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'));
354
- if (!identifier(sessionId) || !identifier(documentId)) throw new TypeError('Session and document IDs must be nonempty strings of at most 256 code units');
365
+ const askedSession = options.sessionId ?? (auto ? 'page-' + auto : mint('session')), askedDocument = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
366
+ if (!identifier(askedSession) || !identifier(askedDocument)) throw coded('invalid_id', TypeError, 'Session and document IDs must be nonempty strings of at most 256 code units');
367
+ const sessionId = askedSession, documentId = askedDocument, pairKey = JSON.stringify([sessionId, documentId]), openElsewhere = !!slotLive.get(pairKey)?.();
355
368
  const agentName = options.agentName;
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');
357
- if (options.theme !== undefined && !themeValue(options.theme)) throw new TypeError('Theme must be light, dark or system');
358
- if (options.save !== undefined && typeof options.save !== 'function') throw new TypeError('save must be a durable storage callback');
359
- if (options.assets !== undefined && typeof options.assets !== 'function') throw new TypeError('assets must be a picture storage callback');
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;
369
+ if (agentName !== undefined && (typeof agentName !== 'string' || !agentName.trim() || agentName.length > 96 || /[\u0000-\u001f\u007f]/.test(agentName))) throw coded('invalid_option', TypeError, 'agentName must be a host-given name of at most 96 characters');
370
+ if (options.theme !== undefined && !themeValue(options.theme)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
371
+ if (options.save !== undefined && typeof options.save !== 'function') throw coded('invalid_option', TypeError, 'save must be a durable storage callback');
372
+ if (options.assets !== undefined && typeof options.assets !== 'function') throw coded('invalid_option', TypeError, 'assets must be a picture storage callback');
373
+ if (options.openFile !== undefined && typeof options.openFile !== 'function') throw coded('invalid_option', TypeError, 'openFile must choose a File or null');
374
+ if (options.timeout !== undefined && !(Number.isSafeInteger(options.timeout) && options.timeout > 0)) throw coded('invalid_option', TypeError, 'timeout is a whole number of milliseconds');
375
+ const bootTimeout = options.timeout ?? 15000, loadTimeout = Math.max(bootTimeout, 120000);
363
376
  const settings = snapshotSettings(options.settings), policy = normalizeSettings(settings), storeAsset = options.assets;
364
377
  const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
365
378
  if (unread.length) throw Object.assign(new TypeError('The reader has no ' + unread.join(', ') + '; its features are ' + READER_FEATURES.join(', ')), {code: 'settings_feature_unavailable'});
@@ -373,10 +386,10 @@ function mount(target, options = {}) {
373
386
  ...(options.onClose ? ['close'] : []), ...(options.agent ? ['agent'] : []), ...(storeAsset ? ['assets'] : []),
374
387
  ];
375
388
  if (options.agent && (!capabilities.includes('open') || !capabilities.includes('read')))
376
- throw new TypeError('Agent access needs both load and save');
389
+ throw coded('invalid_option', TypeError, 'Agent access needs both load and save');
377
390
  const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
378
- let port = null, posted = false, connected = false, ended = false, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
379
- let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, frameFacts = {};
391
+ let watcher = null, port = null, posted = false, connected = false, ended = false, endError = null, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
392
+ let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, loadTimer = 0, timedOut = false, frameFacts = {};
380
393
  let held = null;
381
394
  const ready = deferred();
382
395
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
@@ -386,7 +399,7 @@ function mount(target, options = {}) {
386
399
  waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
387
400
  };
388
401
  function send(type, payload, baseRevision, quiet = false) {
389
- if (ended || !connected || !port) return Promise.reject(new Error('The ' + noun + ' connection has ended or is not connected'));
402
+ if (ended || !connected || !port) return Promise.reject(endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended or is not connected'));
390
403
  if (type === 'load') cancelOpen('open_stale');
391
404
  const requestId = 'host-' + ++seq;
392
405
  const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload !== undefined ? {payload} : {})};
@@ -424,7 +437,7 @@ function mount(target, options = {}) {
424
437
  try {
425
438
  const result = await options.save({...payload, requestId, baseRevision});
426
439
  if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
427
- throw new TypeError('save must return an advancing durable revision');
440
+ throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
428
441
  return {type: 'save-ack', ...ids, requestId, baseRevision, payload: {revision: result.revision}};
429
442
  } catch (error) {
430
443
  return {type: 'save-nack', ...ids, requestId, baseRevision, payload: error?.code === 'conflict' && revision(error.currentRevision)
@@ -554,7 +567,8 @@ function mount(target, options = {}) {
554
567
  }
555
568
  if (type === 'connected') {
556
569
  if (connected) return;
557
- clearTimeout(bootTimer);
570
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
571
+ if (timedOut) { timedOut = false; connectedWait = deferred(); prepareBoot(); }
558
572
  connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
559
573
  connectedWait.resolve(); emit('connected', payload); return;
560
574
  }
@@ -578,7 +592,7 @@ function mount(target, options = {}) {
578
592
  }
579
593
  if (type === 'close-request' && options.onClose) {
580
594
  Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
581
- if (!['save', 'discard', 'cancel'].includes(decision)) throw new TypeError('Close needs save, discard or cancel');
595
+ if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
582
596
  if (channel !== port || ended) return;
583
597
  channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
584
598
  if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
@@ -606,7 +620,7 @@ function mount(target, options = {}) {
606
620
  if (currentStyle) await send('style', currentStyle, undefined, true);
607
621
  if (options.load !== undefined) {
608
622
  const value = typeof options.load === 'function' ? await options.load() : options.load;
609
- if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw new TypeError('load needs content and its revision');
623
+ if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
610
624
  await send('load', value, null);
611
625
  held = {revision: value.revision, content: value.content, filename: value.filename};
612
626
  }
@@ -621,26 +635,27 @@ function mount(target, options = {}) {
621
635
  port.onmessage = event => onPortMessage(event, channel.port1);
622
636
  port.onmessageerror = () => {
623
637
  if (channel.port1 !== port || ended) return;
624
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The ' + noun + ' connection was lost'));
638
+ cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
625
639
  };
626
640
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
627
641
  catch (error) { port.close(); port = null; posted = false; fail(error); }
628
642
  }
629
643
  function bootFailed(code, reason) {
630
- clearTimeout(bootTimer);
644
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
631
645
  if (connected || ended) return;
646
+ timedOut = code === 'connect_timeout' || code === 'load_timeout';
632
647
  connectedWait.reject(refusal(code, reason));
633
648
  }
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.';
649
+ const connectTimeoutReason = () => 'The ' + noun + ' page loaded but did not answer within ' + bootTimeout / 1000 + ' seconds. The address may not be a Rapier page (a missing file loads an error page), the page may not allow this parent (frame-ancestors or X-Frame-Options), or the device may be too slow; a slow device that answers later still connects.';
635
650
  function onLoad() {
636
651
  if (sawLoad && posted) {
637
652
  cancelOpen('open_disconnected');
638
- rejectPending(new Error('The ' + noun + ' reconnected')); port?.close();
653
+ rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
639
654
  assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
640
655
  connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
641
656
  }
642
657
  sawLoad = true;
643
- clearTimeout(bootTimer);
658
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
644
659
  if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
645
660
  connect();
646
661
  }
@@ -652,6 +667,19 @@ function mount(target, options = {}) {
652
667
  const next = forward ? controls[0] : controls.at(-1);
653
668
  if (next) next.focus(); else iframe.blur();
654
669
  }
670
+ function end(removed) {
671
+ if (ended) return;
672
+ if (connected && !removed) send('disconnect', undefined, undefined, true).catch(() => {});
673
+ ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect();
674
+ cancelOpen('open_disconnected'); openAnswers.clear();
675
+ assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
676
+ const error = removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
677
+ if (removed) endError = error;
678
+ rejectPending(error); connectedWait.reject(error); ready.reject(error);
679
+ host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
680
+ port?.close(); port = null; listeners.clear();
681
+ if (!removed && iframe !== target) iframe.remove();
682
+ }
655
683
  function onWindowMessage(event) {
656
684
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
657
685
  if (event.data?.type === 'rapier-ready') connect();
@@ -684,9 +712,9 @@ function mount(target, options = {}) {
684
712
  },
685
713
  async save() {
686
714
  await boot;
687
- if (!options.save) throw new Error('No save callback was granted');
688
- if (ended) throw new Error('The ' + noun + ' connection has ended');
689
- if (readOnly) throw new Error('The document is read-only');
715
+ if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
716
+ if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
717
+ if (readOnly) throw coded('read_only', Error, 'The document is read-only');
690
718
  if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
691
719
  const promise = activeSave.promise;
692
720
  requestCapture(); return promise;
@@ -694,43 +722,41 @@ function mount(target, options = {}) {
694
722
  async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
695
723
  async close() {
696
724
  await boot;
697
- if (!options.onClose) throw new Error('No close callback was granted');
725
+ if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
726
+ if (!options.onClose) throw coded('close_unavailable', Error, 'No close callback was granted');
698
727
  if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
699
728
  return activeClose.promise;
700
729
  },
701
730
  async theme(value) {
702
- if (!themeValue(value)) throw new TypeError('Theme must be light, dark or system');
703
- currentTheme = value; await boot; return send('theme', {theme: value}, undefined, true);
731
+ if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
732
+ currentTheme = value;
733
+ try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
704
734
  },
705
735
  async style(value) {
706
- if (!reader) throw new TypeError('style restyles the reader build');
707
- if (!capabilities.includes('open')) throw new TypeError('Reader styling requires load');
708
- currentStyle = readerStyle(value); await boot; return send('style', currentStyle, undefined, true);
709
- },
710
- disconnect() {
711
- if (ended) return;
712
- if (connected) send('disconnect', undefined, undefined, true).catch(() => {});
713
- ended = true; connected = false; clearTimeout(bootTimer);
714
- cancelOpen('open_disconnected'); openAnswers.clear();
715
- assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
716
- const error = new Error('The ' + noun + ' connection has ended');
717
- rejectPending(error); connectedWait.reject(error); ready.reject(error);
718
- host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
719
- port?.close(); port = null; listeners.clear();
720
- if (iframe !== target) iframe.remove();
736
+ if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
737
+ if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
738
+ currentStyle = readerStyle(value);
739
+ try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
721
740
  },
741
+ disconnect() { end(false); },
722
742
  on(type, fn) {
723
- if (typeof fn !== 'function') throw new TypeError('An event listener must be a function');
743
+ if (typeof fn !== 'function') throw coded('invalid_option', TypeError, 'An event listener must be a function');
724
744
  if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn);
725
745
  return () => listeners.get(type)?.delete(fn);
726
746
  },
727
747
  });
728
748
  if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
749
+ if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
729
750
  prepareBoot();
751
+ if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
730
752
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
731
753
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
732
754
  const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
733
755
  if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
756
+ if (openElsewhere) {
757
+ queueMicrotask(() => bootFailed('document_open_elsewhere', 'Another editor on this page already has this document open (the same sessionId and documentId). Give each editor its own documentId.'));
758
+ return handle;
759
+ }
734
760
  const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
735
761
  if (blocked) {
736
762
  if (iframe !== target) target.append(iframe);
@@ -740,7 +766,14 @@ function mount(target, options = {}) {
740
766
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
741
767
  iframe.src = source.href;
742
768
  if (iframe !== target) target.append(iframe);
743
- bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
769
+ if (host.MutationObserver) {
770
+ let attached = iframe.isConnected;
771
+ watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
772
+ watcher.observe(host.document, {childList: true, subtree: true});
773
+ const root = iframe.getRootNode();
774
+ if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
775
+ }
776
+ loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
744
777
  return handle;
745
778
  }
746
779
 
@@ -885,7 +918,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
885
918
  src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
886
919
  load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
887
920
  save: ({content, baseRevision}) => {
888
- if (this._generation !== generation) throw new Error('The form field was replaced');
921
+ if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
889
922
  if (baseRevision !== this._revision) throw conflict(this._revision);
890
923
  this._value = content; this._revision++; this._dirty = false; this._updateValue();
891
924
  this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
@@ -921,7 +954,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
921
954
  }
922
955
 
923
956
  export function defineRapierEditor(registry = globalThis.customElements) {
924
- if (!registry || !globalThis.ElementInternals) throw new Error('rapier-editor needs form-associated custom elements');
957
+ if (!registry || !globalThis.ElementInternals) throw coded('unsupported_environment', Error, 'rapier-editor needs form-associated custom elements');
925
958
  if (!registry.get('rapier-editor')) registry.define('rapier-editor', RapierEditor);
926
959
  return RapierEditor;
927
960
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.2.40",
3
+ "version": "1.2.42",
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",