rapier-embed 1.2.40 → 1.2.43

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.43';
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, ...(typeof payload?.reason === 'string' ? {reason: payload.reason} : {})},
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); };
@@ -385,8 +398,8 @@ function mount(target, options = {}) {
385
398
  for (const job of waiting.values()) job.reject(error);
386
399
  waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
387
400
  };
388
- 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'));
401
+ function send(type, payload, baseRevision, quiet = false, deadline = 0) {
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} : {})};
@@ -394,6 +407,10 @@ function mount(target, options = {}) {
394
407
  if (!quiet) waiting.set(requestId, pending);
395
408
  try { port.postMessage(message); if (quiet) pending.resolve(); }
396
409
  catch (error) { waiting.delete(requestId); pending.reject(error); }
410
+ if (deadline > 0 && !quiet) {
411
+ const timer = setTimeout(() => { if (waiting.delete(requestId)) pending.reject(coded(type + '_timeout', Error, 'The ' + noun + ' did not answer within ' + deadline / 1000 + ' seconds')); }, deadline);
412
+ pending.promise.then(() => clearTimeout(timer), () => clearTimeout(timer));
413
+ }
397
414
  return pending.promise;
398
415
  }
399
416
  function requestCapture() {
@@ -424,7 +441,7 @@ function mount(target, options = {}) {
424
441
  try {
425
442
  const result = await options.save({...payload, requestId, baseRevision});
426
443
  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');
444
+ throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
428
445
  return {type: 'save-ack', ...ids, requestId, baseRevision, payload: {revision: result.revision}};
429
446
  } catch (error) {
430
447
  return {type: 'save-nack', ...ids, requestId, baseRevision, payload: error?.code === 'conflict' && revision(error.currentRevision)
@@ -554,7 +571,8 @@ function mount(target, options = {}) {
554
571
  }
555
572
  if (type === 'connected') {
556
573
  if (connected) return;
557
- clearTimeout(bootTimer);
574
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
575
+ if (timedOut) { timedOut = false; connectedWait = deferred(); prepareBoot(); }
558
576
  connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
559
577
  connectedWait.resolve(); emit('connected', payload); return;
560
578
  }
@@ -578,7 +596,7 @@ function mount(target, options = {}) {
578
596
  }
579
597
  if (type === 'close-request' && options.onClose) {
580
598
  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');
599
+ if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
582
600
  if (channel !== port || ended) return;
583
601
  channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
584
602
  if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
@@ -606,7 +624,7 @@ function mount(target, options = {}) {
606
624
  if (currentStyle) await send('style', currentStyle, undefined, true);
607
625
  if (options.load !== undefined) {
608
626
  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');
627
+ if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
610
628
  await send('load', value, null);
611
629
  held = {revision: value.revision, content: value.content, filename: value.filename};
612
630
  }
@@ -621,26 +639,27 @@ function mount(target, options = {}) {
621
639
  port.onmessage = event => onPortMessage(event, channel.port1);
622
640
  port.onmessageerror = () => {
623
641
  if (channel.port1 !== port || ended) return;
624
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The ' + noun + ' connection was lost'));
642
+ cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
625
643
  };
626
644
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
627
645
  catch (error) { port.close(); port = null; posted = false; fail(error); }
628
646
  }
629
647
  function bootFailed(code, reason) {
630
- clearTimeout(bootTimer);
648
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
631
649
  if (connected || ended) return;
650
+ timedOut = code === 'connect_timeout' || code === 'load_timeout';
632
651
  connectedWait.reject(refusal(code, reason));
633
652
  }
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.';
653
+ 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
654
  function onLoad() {
636
655
  if (sawLoad && posted) {
637
656
  cancelOpen('open_disconnected');
638
- rejectPending(new Error('The ' + noun + ' reconnected')); port?.close();
657
+ rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
639
658
  assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
640
659
  connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
641
660
  }
642
661
  sawLoad = true;
643
- clearTimeout(bootTimer);
662
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
644
663
  if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
645
664
  connect();
646
665
  }
@@ -652,6 +671,19 @@ function mount(target, options = {}) {
652
671
  const next = forward ? controls[0] : controls.at(-1);
653
672
  if (next) next.focus(); else iframe.blur();
654
673
  }
674
+ function end(removed) {
675
+ if (ended) return;
676
+ if (connected && !removed) send('disconnect', undefined, undefined, true).catch(() => {});
677
+ ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect();
678
+ cancelOpen('open_disconnected'); openAnswers.clear();
679
+ assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
680
+ const error = removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
681
+ if (removed) endError = error;
682
+ rejectPending(error); connectedWait.reject(error); ready.reject(error);
683
+ host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
684
+ port?.close(); port = null; listeners.clear();
685
+ if (!removed && iframe !== target) iframe.remove();
686
+ }
655
687
  function onWindowMessage(event) {
656
688
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
657
689
  if (event.data?.type === 'rapier-ready') connect();
@@ -678,15 +710,15 @@ function mount(target, options = {}) {
678
710
  throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
679
711
  if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
680
712
  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);
713
+ const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null, false, loadTimeout);
682
714
  held = {revision: version, content, filename};
683
715
  return loaded;
684
716
  },
685
717
  async save() {
686
718
  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');
719
+ if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
720
+ if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
721
+ if (readOnly) throw coded('read_only', Error, 'The document is read-only');
690
722
  if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
691
723
  const promise = activeSave.promise;
692
724
  requestCapture(); return promise;
@@ -694,43 +726,41 @@ function mount(target, options = {}) {
694
726
  async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
695
727
  async close() {
696
728
  await boot;
697
- if (!options.onClose) throw new Error('No close callback was granted');
729
+ if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
730
+ if (!options.onClose) throw coded('close_unavailable', Error, 'No close callback was granted');
698
731
  if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
699
732
  return activeClose.promise;
700
733
  },
701
734
  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);
735
+ if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
736
+ currentTheme = value;
737
+ try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
704
738
  },
705
739
  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();
740
+ if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
741
+ if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
742
+ currentStyle = readerStyle(value);
743
+ try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
721
744
  },
745
+ disconnect() { end(false); },
722
746
  on(type, fn) {
723
- if (typeof fn !== 'function') throw new TypeError('An event listener must be a function');
747
+ if (typeof fn !== 'function') throw coded('invalid_option', TypeError, 'An event listener must be a function');
724
748
  if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn);
725
749
  return () => listeners.get(type)?.delete(fn);
726
750
  },
727
751
  });
728
752
  if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
753
+ if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
729
754
  prepareBoot();
755
+ if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
730
756
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
731
757
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
732
758
  const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
733
759
  if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
760
+ if (openElsewhere) {
761
+ 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.'));
762
+ return handle;
763
+ }
734
764
  const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
735
765
  if (blocked) {
736
766
  if (iframe !== target) target.append(iframe);
@@ -740,7 +770,14 @@ function mount(target, options = {}) {
740
770
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
741
771
  iframe.src = source.href;
742
772
  if (iframe !== target) target.append(iframe);
743
- bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
773
+ if (host.MutationObserver) {
774
+ let attached = iframe.isConnected;
775
+ watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
776
+ watcher.observe(host.document, {childList: true, subtree: true});
777
+ const root = iframe.getRootNode();
778
+ if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
779
+ }
780
+ loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
744
781
  return handle;
745
782
  }
746
783
 
@@ -885,7 +922,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
885
922
  src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
886
923
  load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
887
924
  save: ({content, baseRevision}) => {
888
- if (this._generation !== generation) throw new Error('The form field was replaced');
925
+ if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
889
926
  if (baseRevision !== this._revision) throw conflict(this._revision);
890
927
  this._value = content; this._revision++; this._dirty = false; this._updateValue();
891
928
  this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
@@ -921,7 +958,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
921
958
  }
922
959
 
923
960
  export function defineRapierEditor(registry = globalThis.customElements) {
924
- if (!registry || !globalThis.ElementInternals) throw new Error('rapier-editor needs form-associated custom elements');
961
+ if (!registry || !globalThis.ElementInternals) throw coded('unsupported_environment', Error, 'rapier-editor needs form-associated custom elements');
925
962
  if (!registry.get('rapier-editor')) registry.define('rapier-editor', RapierEditor);
926
963
  return RapierEditor;
927
964
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.2.40",
3
+ "version": "1.2.43",
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",