rapier-embed 1.2.33 → 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} = (() => {
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));
@@ -238,74 +239,143 @@ function createAssetBroker({current, send}) {
238
239
  function lookup(url) { scope(); return retained.get(url)?.asset || null; }
239
240
  return Object.freeze({request, answer, cancel, lookup});
240
241
  }
241
- return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker};
242
+
243
+ function pictureBase(value) {
244
+ if (value === undefined || value === null) return null;
245
+ let base;
246
+ try {
247
+ if (typeof value !== 'string' || !value || value.length > 4096 || /[\s\u0000-\u001f\u007f<>\\]/u.test(value)) throw 0;
248
+ base = new URL(value);
249
+ if (base.username || base.password || !(base.protocol === 'https:' || base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw 0;
250
+ } catch (_) { throw refusal('invalid_base_url', 'baseUrl is an absolute https address, or http on localhost.'); }
251
+ const vouched = new Set();
252
+ return Object.assign(src => {
253
+ const text = String(src || '').trim();
254
+ if (!text || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)) return null;
255
+ try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
256
+ }, {vouched});
257
+ }
258
+ return {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
242
259
  })();
243
260
  const BUILDS = Object.freeze({full: 'https://rapier.website/embed/rapier.html', reader: 'https://rapier.website/embed/rapier-reader.html'});
261
+ const PACKAGE_VERSION = '1.2.42';
262
+ const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
263
+ 'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
264
+ const READER_FEATURES = ['find', 'readAloud', 'share'];
244
265
  const DEFAULT_SRC = BUILDS.full;
245
266
  const revision = value => Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && value.length > 0 && value.length <= 256;
246
- 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();
247
268
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
248
269
  const themeValue = value => ['light', 'dark', 'system'].includes(value);
249
270
  const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
271
+ const autoLive = new Map(), slotLive = new Map();
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); };
273
+ function autoIdentity(options) {
274
+ const load = options.load;
275
+ if (!options.save || !load || typeof load !== 'object' || typeof load.content !== 'string') return null;
276
+ const base = fnv(location.origin + location.pathname) + '-' + fnv(String(load.filename ?? '') + '|' + load.content.length + '|' + load.content.slice(0, 2048));
277
+ let at = 0;
278
+ while (autoLive.get(base + '-' + at)?.()) at++;
279
+ return base + '-' + at;
280
+ }
250
281
  function deferred() {
251
282
  let resolve, reject;
252
283
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
253
284
  promise.catch(() => {});
254
285
  return {promise, resolve, reject};
255
286
  }
256
- 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},
288
+ revision(payload?.currentRevision) ? {currentRevision: payload.currentRevision} : {});
257
289
 
258
290
  function conflict(currentRevision) {
259
- 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');
260
292
  return Object.assign(new Error('The stored document has changed'), {code: 'conflict', currentRevision});
261
293
  }
262
294
 
263
295
  function readerStyle(value) {
264
- if (!record(value) || Object.keys(value).some(key => !['css', 'fonts'].includes(key))) throw new TypeError('style takes css and fonts');
265
- 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');
266
298
  const face = row => record(row) && typeof row.family === 'string' && row.family.length > 0 &&
267
299
  (typeof row.source === 'string' || row.source instanceof ArrayBuffer || ArrayBuffer.isView(row.source)) &&
268
300
  (row.descriptors === undefined || record(row.descriptors)) && Object.keys(row).every(key => ['family', 'source', 'descriptors'].includes(key));
269
- 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');
270
302
  return {...(value.css !== undefined ? {css: value.css} : {}), ...(value.fonts !== undefined ? {fonts: value.fonts.map(row => ({...row}))} : {})};
271
303
  }
272
304
 
305
+ const HOSTED_ORIGIN = new URL(BUILDS.full).origin;
306
+ function framingProblem(host, source, sandbox, noun) {
307
+ const here = host.location, local = ['localhost', '127.0.0.1', '[::1]'].includes(here.hostname);
308
+ if (here.protocol !== 'https:' && here.protocol !== 'http:')
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.'};
310
+ if (source.origin === HOSTED_ORIGIN && here.protocol === 'http:' && !local)
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.'};
318
+ if (source.origin === HOSTED_ORIGIN && host.crossOriginIsolated === true)
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.'};
320
+ if (sandbox) for (const token of ['allow-scripts', 'allow-same-origin']) if (!sandbox.contains(token))
321
+ 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.'};
322
+ return null;
323
+ }
324
+
273
325
  function mount(target, options = {}) {
274
326
  const document = target?.ownerDocument;
275
- 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');
329
+ const unknown = Object.keys(options).filter(key => !OPTIONS.includes(key));
330
+ if (unknown.length) throw Object.assign(new TypeError('Unknown option: ' + unknown.join(', ') + '. Mount takes ' + OPTIONS.join(', ')), {code: 'option_unknown'});
276
331
  const build = options.build === undefined || options.build === 'document' ? 'full' : options.build;
277
- if (!Object.hasOwn(BUILDS, build)) throw new TypeError('build must be full or reader');
278
- const reader = build === 'reader';
332
+ if (!Object.hasOwn(BUILDS, build)) throw coded('invalid_build', TypeError, 'build must be full or reader');
333
+ const reader = build === 'reader', noun = reader ? 'reader' : 'editor';
279
334
  if (reader && ['save', 'compare', 'onClose', 'agent', 'agentName', 'assets'].some(key => options[key] !== undefined && options[key] !== false))
280
- throw new TypeError('The reader takes load, onState, theme, settings, style and plugins');
281
- if (!reader && options.style !== undefined) throw new TypeError('style restyles the reader build');
282
- 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');
283
338
  if (options.plugins !== undefined && (typeof options.plugins !== 'string' || !options.plugins || options.plugins.length > 2048 || /[\u0000-\u001f\u007f]/.test(options.plugins)))
284
- throw new TypeError('plugins is the address of the directory that holds the plug-in files');
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');
285
341
  let currentStyle = options.style === undefined ? undefined : readerStyle(options.style);
286
342
  const host = document.defaultView, iframe = target.tagName === 'IFRAME' ? target : document.createElement('iframe');
287
- 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'); }
288
348
  if (source.username || source.password || !(source.protocol === 'https:' || source.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(source.hostname)))
289
- 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');
350
+ const page = source.pathname.split('/').pop();
351
+ if (reader ? page === 'rapier.html' : page === 'rapier-reader.html')
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'});
290
353
  source.searchParams.set('embed', '1');
291
354
  if (options.plugins !== undefined) {
292
355
  let directory;
293
- 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'); }
294
357
  if (!(directory.protocol === 'https:' || directory.protocol === 'http:') || directory.username || directory.password || directory.search || directory.hash)
295
- 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');
296
359
  if (directory.origin !== source.origin)
297
- 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');
298
361
  source.searchParams.set('plugins', directory.href);
299
362
  }
300
- const origin = source.origin, sessionId = options.sessionId ?? mint('session'), documentId = options.documentId ?? mint('document');
301
- if (!identifier(sessionId) || !identifier(documentId)) throw new TypeError('Session and document IDs must be nonempty strings of at most 256 code units');
363
+ if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
364
+ const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options) : null;
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)?.();
302
368
  const agentName = options.agentName;
303
- 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');
304
- if (options.theme !== undefined && !themeValue(options.theme)) throw new TypeError('Theme must be light, dark or system');
305
- if (options.save !== undefined && typeof options.save !== 'function') throw new TypeError('save must be a durable storage callback');
306
- if (options.assets !== undefined && typeof options.assets !== 'function') throw new TypeError('assets must be a picture storage callback');
307
- if (options.openFile !== undefined && typeof options.openFile !== 'function') throw new TypeError('openFile must choose a File or null');
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);
308
376
  const settings = snapshotSettings(options.settings), policy = normalizeSettings(settings), storeAsset = options.assets;
377
+ const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
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'});
309
379
  const assetAnswers = new Map();
310
380
  let assetPending = '', assetAbort = new AbortController();
311
381
  const chooseFile = options.openFile || (({signal}) => pickDeviceFile(document, signal)), openAnswers = new Map();
@@ -316,10 +386,11 @@ function mount(target, options = {}) {
316
386
  ...(options.onClose ? ['close'] : []), ...(options.agent ? ['agent'] : []), ...(storeAsset ? ['assets'] : []),
317
387
  ];
318
388
  if (options.agent && (!capabilities.includes('open') || !capabilities.includes('read')))
319
- throw new TypeError('Agent access needs both load and save');
389
+ throw coded('invalid_option', TypeError, 'Agent access needs both load and save');
320
390
  const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
321
- 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;
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 = {};
393
+ let held = null;
323
394
  const ready = deferred();
324
395
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
325
396
  const fail = error => { emit('error', error); options.onError?.(error); };
@@ -328,7 +399,7 @@ function mount(target, options = {}) {
328
399
  waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
329
400
  };
330
401
  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'));
402
+ if (ended || !connected || !port) return Promise.reject(endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended or is not connected'));
332
403
  if (type === 'load') cancelOpen('open_stale');
333
404
  const requestId = 'host-' + ++seq;
334
405
  const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload !== undefined ? {payload} : {})};
@@ -339,6 +410,7 @@ function mount(target, options = {}) {
339
410
  return pending.promise;
340
411
  }
341
412
  function requestCapture() {
413
+ if (activeSave) activeSave.commandId = 'host-' + (seq + 1);
342
414
  send('save', undefined, undefined, true).catch(error => { activeSave?.reject(error); activeSave = null; });
343
415
  }
344
416
  async function answerSave(message, channel) {
@@ -365,7 +437,7 @@ function mount(target, options = {}) {
365
437
  try {
366
438
  const result = await options.save({...payload, requestId, baseRevision});
367
439
  if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
368
- throw new TypeError('save must return an advancing durable revision');
440
+ throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
369
441
  return {type: 'save-ack', ...ids, requestId, baseRevision, payload: {revision: result.revision}};
370
442
  } catch (error) {
371
443
  return {type: 'save-nack', ...ids, requestId, baseRevision, payload: error?.code === 'conflict' && revision(error.currentRevision)
@@ -379,7 +451,7 @@ function mount(target, options = {}) {
379
451
  if (ended || channel !== port) return;
380
452
  channel.postMessage(reply);
381
453
  if (reply.type === 'save-ack') {
382
- lastSaved = reply.payload;
454
+ lastSaved = reply.payload; held = {revision: reply.payload.revision, content: payload.content, filename: payload.filename};
383
455
  if (activeSave) activeSave.acknowledged = true;
384
456
  } else if (activeSave) { activeSave.reject(refused(reply.payload)); activeSave = null; }
385
457
  }
@@ -495,7 +567,10 @@ function mount(target, options = {}) {
495
567
  }
496
568
  if (type === 'connected') {
497
569
  if (connected) return;
498
- connected = true; connectedWait.resolve(); emit('connected', payload); return;
570
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
571
+ if (timedOut) { timedOut = false; connectedWait = deferred(); prepareBoot(); }
572
+ connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
573
+ connectedWait.resolve(); emit('connected', payload); return;
499
574
  }
500
575
  if (!connected) return;
501
576
  if (type === 'open-request' || type === 'open-cancel') { answerOpen(message, channel); return; }
@@ -505,6 +580,10 @@ function mount(target, options = {}) {
505
580
  if (!['loaded', 'dirty', 'saving', 'closing', 'readOnly'].every(key => typeof payload[key] === 'boolean')
506
581
  || Object.keys(payload).some(key => !['loaded', 'dirty', 'saving', 'closing', 'readOnly', 'filename', 'docKind'].includes(key))) return;
507
582
  readOnly = payload.readOnly; emit('state', payload); options.onState?.(payload);
583
+ if (activeSave && !activeSave.acknowledged && activeSave.commandId && message.requestId === activeSave.commandId && payload.loaded && !payload.dirty && !payload.saving && revision(message.baseRevision)) {
584
+ activeSave.resolve({revision: message.baseRevision}); activeSave = null;
585
+ return;
586
+ }
508
587
  if (activeSave?.acknowledged && !payload.saving && message.baseRevision === lastSaved?.revision) {
509
588
  if (payload.dirty) { activeSave.acknowledged = false; requestCapture(); }
510
589
  else { activeSave.resolve(lastSaved); activeSave = null; }
@@ -513,7 +592,7 @@ function mount(target, options = {}) {
513
592
  }
514
593
  if (type === 'close-request' && options.onClose) {
515
594
  Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
516
- 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');
517
596
  if (channel !== port || ended) return;
518
597
  channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
519
598
  if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
@@ -530,7 +609,9 @@ function mount(target, options = {}) {
530
609
  }
531
610
  if (pending && ['load-ack', 'load-nack', 'compare-ack', 'compare-nack'].includes(type)) {
532
611
  waiting.delete(requestId);
533
- if (type === 'load-ack') readOnly = payload.readOnly === true;
612
+ if (type === 'load-ack') {
613
+ readOnly = payload.readOnly === true;
614
+ }
534
615
  if (type.endsWith('-nack')) pending.reject(refused(payload)); else pending.resolve(payload);
535
616
  }
536
617
  }
@@ -539,8 +620,9 @@ function mount(target, options = {}) {
539
620
  if (currentStyle) await send('style', currentStyle, undefined, true);
540
621
  if (options.load !== undefined) {
541
622
  const value = typeof options.load === 'function' ? await options.load() : options.load;
542
- 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');
543
624
  await send('load', value, null);
625
+ held = {revision: value.revision, content: value.content, filename: value.filename};
544
626
  }
545
627
  ready.resolve(handle);
546
628
  });
@@ -553,24 +635,56 @@ function mount(target, options = {}) {
553
635
  port.onmessage = event => onPortMessage(event, channel.port1);
554
636
  port.onmessageerror = () => {
555
637
  if (channel.port1 !== port || ended) return;
556
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The editor connection was lost'));
638
+ cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
557
639
  };
558
640
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
559
641
  catch (error) { port.close(); port = null; posted = false; fail(error); }
560
642
  }
643
+ function bootFailed(code, reason) {
644
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
645
+ if (connected || ended) return;
646
+ timedOut = code === 'connect_timeout' || code === 'load_timeout';
647
+ connectedWait.reject(refusal(code, reason));
648
+ }
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.';
561
650
  function onLoad() {
562
651
  if (sawLoad && posted) {
563
652
  cancelOpen('open_disconnected');
564
- rejectPending(new Error('The editor reconnected')); port?.close();
653
+ rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
565
654
  assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
566
655
  connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
567
656
  }
568
657
  sawLoad = true;
658
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
659
+ if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
569
660
  connect();
570
661
  }
662
+ function focusBeside(forward) {
663
+ const order = forward ? host.Node.DOCUMENT_POSITION_FOLLOWING : host.Node.DOCUMENT_POSITION_PRECEDING;
664
+ const controls = [...document.querySelectorAll('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]')]
665
+ .filter(element => element !== iframe && element.tabIndex >= 0 && !element.disabled && !element.closest('[inert]') && element.getClientRects().length &&
666
+ iframe.compareDocumentPosition(element) & order);
667
+ const next = forward ? controls[0] : controls.at(-1);
668
+ if (next) next.focus(); else iframe.blur();
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
+ }
571
683
  function onWindowMessage(event) {
572
684
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
573
685
  if (event.data?.type === 'rapier-ready') connect();
686
+ else if (event.data?.type === 'rapier-boot-failed') bootFailed('boot_failed', 'The ' + noun + ' could not start');
687
+ else if (event.data?.type === 'rapier-focus-leave' && connected) focusBeside(event.data.backward !== true);
574
688
  else if (event.data?.type === 'close-ready' && event.data.sessionId === sessionId && event.data.documentId === documentId && options.onClose) {
575
689
  activeClose?.resolve(event.data.payload); activeClose = null; emit('closed', event.data.payload);
576
690
  }
@@ -578,65 +692,92 @@ function mount(target, options = {}) {
578
692
  const handle = Object.freeze({
579
693
  iframe, ready: ready.promise,
580
694
  get connected() { return connected && !ended; },
581
- async load(content, {filename, revision: version, readOnly, title} = {}) {
695
+ get version() { return frameFacts.version ?? null; },
696
+ get contract() { return frameFacts.contract ?? null; },
697
+ async load(content, options = {}) {
698
+ if (record(content)) { options = content; content = options.content; }
699
+ if (typeof content !== 'string') throw Object.assign(new TypeError('load needs the document text as a string'), {code: 'invalid_payload'});
700
+ let {filename, revision: version, readOnly, title, baseUrl} = options;
582
701
  cancelOpen('open_stale');
583
702
  await boot;
584
- return send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {})}, null);
703
+ if (version === undefined) version = typeof held?.revision === 'number' ? held.revision + 1 : 1;
704
+ const sameDocument = held && (filename === undefined || held.filename === undefined || filename === held.filename);
705
+ if (sameDocument && typeof version === 'number' && typeof held.revision === 'number' && version < held.revision)
706
+ throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
707
+ if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
708
+ throw Object.assign(new Error('Revision ' + version + ' already names other content'), {code: 'revision_conflict', currentRevision: held.revision});
709
+ const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null);
710
+ held = {revision: version, content, filename};
711
+ return loaded;
585
712
  },
586
713
  async save() {
587
714
  await boot;
588
- if (!options.save) throw new Error('No save callback was granted');
589
- if (ended) throw new Error('The editor connection has ended');
590
- if (readOnly) throw new Error('The document is read-only');
591
- if (!activeSave) activeSave = {...deferred(), acknowledged: false};
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');
718
+ if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
592
719
  const promise = activeSave.promise;
593
720
  requestCapture(); return promise;
594
721
  },
595
722
  async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
596
723
  async close() {
597
724
  await boot;
598
- 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');
599
727
  if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
600
728
  return activeClose.promise;
601
729
  },
602
730
  async theme(value) {
603
- if (!themeValue(value)) throw new TypeError('Theme must be light, dark or system');
604
- 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; }
605
734
  },
606
735
  async style(value) {
607
- if (!reader) throw new TypeError('style restyles the reader build');
608
- if (!capabilities.includes('open')) throw new TypeError('Reader styling requires load');
609
- currentStyle = readerStyle(value); await boot; return send('style', currentStyle, undefined, true);
610
- },
611
- disconnect() {
612
- if (ended) return;
613
- if (connected) send('disconnect', undefined, undefined, true).catch(() => {});
614
- ended = true; connected = false;
615
- cancelOpen('open_disconnected'); openAnswers.clear();
616
- assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
617
- const error = new Error('The editor connection has ended');
618
- rejectPending(error); connectedWait.reject(error); ready.reject(error);
619
- host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
620
- port?.close(); port = null; listeners.clear();
621
- 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; }
622
740
  },
741
+ disconnect() { end(false); },
623
742
  on(type, fn) {
624
- 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');
625
744
  if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn);
626
745
  return () => listeners.get(type)?.delete(fn);
627
746
  },
628
747
  });
748
+ if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
749
+ if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
629
750
  prepareBoot();
751
+ if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
630
752
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
631
753
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
632
- iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
754
+ const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
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
+ }
760
+ const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
761
+ if (blocked) {
762
+ if (iframe !== target) target.append(iframe);
763
+ queueMicrotask(() => bootFailed(blocked.code, blocked.reason));
764
+ return handle;
765
+ }
633
766
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
634
767
  iframe.src = source.href;
635
768
  if (iframe !== target) target.append(iframe);
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);
636
777
  return handle;
637
778
  }
638
779
 
639
- export const Rapier = Object.freeze({mount, conflict});
780
+ export const Rapier = Object.freeze({mount, conflict, version: PACKAGE_VERSION, contract: 1});
640
781
 
641
782
  const forms = new WeakMap();
642
783
  function joinForm(field, form) {
@@ -645,8 +786,8 @@ function joinForm(field, form) {
645
786
  if (!group) {
646
787
  group = {fields: new Set(), pending: false, releasing: false};
647
788
  group.submit = async event => {
648
- if (group.releasing) return;
649
- event.preventDefault();
789
+ if (event.target !== form || group.releasing) return;
790
+ event.preventDefault(); event.stopImmediatePropagation();
650
791
  if (group.pending) return;
651
792
  group.pending = true;
652
793
  const submitter = event.submitter;
@@ -662,12 +803,12 @@ function joinForm(field, form) {
662
803
  } catch (error) { if (current()) for (const {field} of captured) field._error(error); }
663
804
  finally { group.pending = false; group.releasing = false; }
664
805
  };
665
- form.addEventListener('submit', group.submit, true); forms.set(form, group);
806
+ group.view = form.ownerDocument.defaultView; group.view.addEventListener('submit', group.submit, true); forms.set(form, group);
666
807
  }
667
808
  group.fields.add(field);
668
809
  return () => {
669
810
  group.fields.delete(field);
670
- if (!group.fields.size) { form.removeEventListener('submit', group.submit, true); forms.delete(form); }
811
+ if (!group.fields.size) { group.view.removeEventListener('submit', group.submit, true); forms.delete(form); }
671
812
  };
672
813
  }
673
814
 
@@ -777,7 +918,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
777
918
  src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
778
919
  load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
779
920
  save: ({content, baseRevision}) => {
780
- 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');
781
922
  if (baseRevision !== this._revision) throw conflict(this._revision);
782
923
  this._value = content; this._revision++; this._dirty = false; this._updateValue();
783
924
  this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
@@ -813,7 +954,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
813
954
  }
814
955
 
815
956
  export function defineRapierEditor(registry = globalThis.customElements) {
816
- 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');
817
958
  if (!registry.get('rapier-editor')) registry.define('rapier-editor', RapierEditor);
818
959
  return RapierEditor;
819
960
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.2.33",
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",