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/README.md +112 -30
- package/cli.mjs +19 -6
- package/contract.d.mts +5 -0
- package/contract.mjs +17 -0
- package/embed.d.mts +34 -7
- package/embed.mjs +213 -72
- package/package.json +1 -1
- package/rapier-plugins.json +212 -212
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
|
-
|
|
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
|
|
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
|
|
265
|
-
if (value.css !== undefined && typeof value.css !== 'string') throw
|
|
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
|
|
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
|
|
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
|
|
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
|
|
281
|
-
if (!reader && options.style !== undefined) throw
|
|
282
|
-
if (options.style !== undefined && options.load === undefined) throw
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
304
|
-
if (options.theme !== undefined && !themeValue(options.theme)) throw
|
|
305
|
-
if (options.save !== undefined && typeof options.save !== 'function') throw
|
|
306
|
-
if (options.assets !== undefined && typeof options.assets !== 'function') throw
|
|
307
|
-
if (options.openFile !== undefined && typeof options.openFile !== 'function') throw
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
589
|
-
if (
|
|
590
|
-
if (readOnly) throw
|
|
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 (
|
|
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
|
|
604
|
-
currentTheme = value;
|
|
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
|
|
608
|
-
if (!capabilities.includes('open')) throw
|
|
609
|
-
currentStyle = readerStyle(value);
|
|
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
|
|
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.
|
|
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) {
|
|
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
|
|
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
|
|
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.
|
|
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",
|