rapier-embed 1.1.2

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/embed.mjs +129 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Skipworth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # rapier-embed
2
+
3
+ The Rapier editor inside your site. One call frames `rapier.html?embed=1`, connects at the frame's
4
+ load, loads your document and answers its saves from your own storage. Your app keeps the document,
5
+ its identity and its revisions; the frame never stores it.
6
+
7
+ MIT. No dependencies. The contract it speaks is `docs/embed-contract.md` in the Rapier repository;
8
+ the skill a coding agent reads is `skills/embed-rapier/SKILL.md` there.
9
+
10
+ ## Use it
11
+
12
+ ```html
13
+ <iframe id="rapier" src="https://editor.example.com/rapier.html?embed=1"></iframe>
14
+ ```
15
+
16
+ ```js
17
+ import {connectRapier, memoryStore} from 'rapier-embed';
18
+
19
+ const store = memoryStore({revision: 1, content: '# Notes\n'}); // or your own: write(requestId, content, baseRevision)
20
+ const rapier = connectRapier(document.querySelector('#rapier'), {
21
+ sessionId: 'session-1', documentId: 'doc-42',
22
+ capabilities: ['open', 'read'], // add 'changes', 'compare', 'close', 'agent' as you use them
23
+ theme: 'dark', // 'light' | 'dark' | 'system'; the frame follows your app
24
+ store,
25
+ onConnected: () => rapier.load(store.content, {filename: 'notes.md', revision: store.revision}),
26
+ });
27
+ ```
28
+
29
+ That is the whole integration. The frame asks to save when the person presses Save; the helper
30
+ calls `store.write(requestId, content, baseRevision)` once per request and answers the frame with
31
+ the revision your store returns, or a conflict when your copy moved on. A repeated request (the
32
+ person's Retry, a reconnect) gets the same answer and is never written twice.
33
+
34
+ Your `store.write` returns `{revision}` after the bytes are durable, or `{conflict: true,
35
+ currentRevision}` when `baseRevision` is not your current revision. Return only after the write is
36
+ durable: the frame tells the person "saved to your.site" on your word.
37
+
38
+ ## The handle
39
+
40
+ | Call | Needs | Does |
41
+ |---|---|---|
42
+ | `load(content, {filename, revision, readOnly, title})` | `open` | Opens your text in the frame at that revision. |
43
+ | `save()` | `read` | Asks the frame to save now; the answer comes through `store.write`. |
44
+ | `compare(content, {filename})` | `compare` | Opens your alternative text in the frame's Compare for the person to keep or drop. |
45
+ | `close()` | `close` | Asks the frame to close; resolves with the frame's close-ready. |
46
+ | `theme('light' \| 'dark' \| 'system')` | nothing | Changes the frame's theme live. |
47
+ | `disconnect()` | nothing | Ends this connection and stops listening. |
48
+ | `on(type, fn)` | | `connected`, `state` (with `changes`), `close-request`, `closed`, `error`. |
49
+
50
+ `onState(state)` receives `{loaded, dirty, saving, closing, readOnly, filename, docKind}` whenever it
51
+ changes, with `changes` granted. `onClose({dirty})` returns `'save'`, `'discard'` or `'cancel'` when the
52
+ frame asks; without it a dirty document is saved and a clean one closes.
53
+
54
+ ## What the frame checks, and what you check
55
+
56
+ The frame takes the connect only from its parent window, from an HTTPS origin (HTTP on localhost),
57
+ and binds to that origin for the session. The helper checks every message it takes against the
58
+ frame's window and origin. Without `read` the frame refuses to send the person's words anywhere;
59
+ without `close` its Close control names the missing grant and sends you nothing. Grants are frozen
60
+ for the connection: reload the frame to change them.
61
+
62
+ Serve the frame from its own origin (`editor.example.com`), allow it in your `frame-src`, and let it
63
+ keep a real origin if you sandbox it (`allow-scripts allow-same-origin`).
package/embed.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Host side of docs/embed-contract.md. The host keeps the document; this module stores nothing.
3
+ // A repeated requestId gets the remembered answer, never a second write.
4
+
5
+ const CAPABILITIES = ['open', 'read', 'changes', 'compare', 'close', 'agent'];
6
+
7
+ export function connectRapier(iframe, options) {
8
+ const {sessionId, documentId, store, capabilities = ['open', 'read'], theme, onClose, onState, onError,
9
+ origin = new URL(iframe.src, globalThis.location?.href).origin, onConnected} = options;
10
+ if (typeof sessionId !== 'string' || !sessionId || typeof documentId !== 'string' || !documentId) throw new TypeError('sessionId and documentId are nonempty strings');
11
+ if (!Array.isArray(capabilities) || capabilities.some(name => !CAPABILITIES.includes(name))) throw new TypeError('capabilities is a list from ' + CAPABILITIES.join(', '));
12
+ if (capabilities.includes('read') && typeof store?.write !== 'function') throw new TypeError('read needs store.write(requestId, content, baseRevision) -> {revision} or {conflict: true, currentRevision}');
13
+ const ids = {sessionId, documentId};
14
+ const answered = new Map(), waiting = new Map();
15
+ let port = null, posted = false, connected = false, seq = 0;
16
+ const listeners = new Map();
17
+
18
+ const emit = (type, message) => { for (const fn of listeners.get(type) || []) fn(message); };
19
+ const fail = error => { emit('error', error); if (onError) onError(error); };
20
+ const sameFrame = event => event.source === iframe.contentWindow && event.origin === origin;
21
+
22
+ function send(type, payload, baseRevision) {
23
+ if (!port || !connected) return Promise.reject(new Error('not connected'));
24
+ const requestId = 'h' + (++seq) + '-' + Math.random().toString(36).slice(2, 10);
25
+ const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload ? {payload} : {})};
26
+ return new Promise((resolve, reject) => {
27
+ waiting.set(requestId, {resolve, reject, type});
28
+ try { port.postMessage(message); } catch (error) { waiting.delete(requestId); reject(error); }
29
+ });
30
+ }
31
+
32
+ async function answerSave(message) {
33
+ const {requestId, baseRevision} = message;
34
+ // The answer is held from the moment storage starts: a Retry while it is pending shares the one write.
35
+ if (!answered.has(requestId)) answered.set(requestId, (async () => {
36
+ let reply;
37
+ try {
38
+ const outcome = await store.write(requestId, message.payload?.content ?? '', baseRevision, message.payload);
39
+ reply = outcome && outcome.conflict
40
+ ? {type: 'save-nack', payload: {code: 'conflict', ...(outcome.currentRevision !== undefined ? {currentRevision: outcome.currentRevision} : {})}}
41
+ : {type: 'save-ack', payload: {revision: outcome.revision}};
42
+ } catch (error) {
43
+ reply = {type: 'save-nack', payload: {code: 'failed', reason: String(error?.message || error).slice(0, 500)}};
44
+ }
45
+ return {...ids, requestId, baseRevision, ...reply};
46
+ })());
47
+ port.postMessage(await answered.get(requestId));
48
+ }
49
+
50
+ function onPortMessage(event) {
51
+ const message = event.data;
52
+ if (!message || typeof message !== 'object' || message.sessionId !== sessionId || message.documentId !== documentId) return;
53
+ const pending = message.requestId ? waiting.get(message.requestId) : null;
54
+ switch (message.type) {
55
+ case 'connected':
56
+ connected = true;
57
+ emit('connected', message.payload);
58
+ if (onConnected) onConnected(message.payload);
59
+ return;
60
+ case 'save-request': answerSave(message).catch(fail); return;
61
+ case 'document-state': emit('state', message.payload); if (onState) onState(message.payload); return;
62
+ case 'close-request': {
63
+ const decide = decision => port.postMessage({type: 'close-decision', ...ids, requestId: message.requestId, baseRevision: message.baseRevision, payload: {decision}});
64
+ const dirty = !!message.payload?.dirty;
65
+ Promise.resolve(onClose ? onClose({dirty}) : (dirty ? 'save' : 'discard')).then(decide, fail);
66
+ emit('close-request', message.payload);
67
+ return;
68
+ }
69
+ case 'close-ready': emit('closed', message.payload); if (pending) { waiting.delete(message.requestId); pending.resolve(message.payload); } return;
70
+ case 'protocol-error':
71
+ if (pending) { waiting.delete(message.requestId); pending.reject(Object.assign(new Error(message.payload?.reason || message.payload?.code || 'refused'), {code: message.payload?.code, payload: message.payload})); }
72
+ else fail(Object.assign(new Error(message.payload?.reason || message.payload?.code || 'refused'), {code: message.payload?.code, payload: message.payload}));
73
+ return;
74
+ default:
75
+ if (pending) { waiting.delete(message.requestId); pending.resolve(message.payload); }
76
+ else emit(message.type, message.payload);
77
+ }
78
+ }
79
+
80
+ function connect() {
81
+ if (posted) return;
82
+ posted = true;
83
+ const channel = new MessageChannel();
84
+ port = channel.port1;
85
+ port.onmessage = onPortMessage;
86
+ iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, ...(theme ? {theme} : {})}, origin, [channel.port2]);
87
+ }
88
+
89
+ function onWindowMessage(event) {
90
+ if (!sameFrame(event)) return;
91
+ const message = event.data;
92
+ if (message?.type === 'rapier-ready' && !posted) connect();
93
+ else if (message?.type === 'close-ready' && message.sessionId === sessionId && message.documentId === documentId) emit('closed', message.payload);
94
+ }
95
+
96
+ globalThis.addEventListener?.('message', onWindowMessage);
97
+ iframe.addEventListener('load', connect);
98
+
99
+ return Object.freeze({
100
+ get connected() { return connected; },
101
+ load: (content, {filename, revision = 1, readOnly, title} = {}) => send('load', {content, ...(filename ? {filename} : {}), revision, ...(readOnly !== undefined ? {readOnly} : {}), ...(title ? {title} : {})}, null),
102
+ save: () => send('save'),
103
+ compare: (content, {filename} = {}) => send('compare', {content, ...(filename ? {filename} : {})}),
104
+ close: () => send('close'),
105
+ theme: value => send('theme', {theme: value}),
106
+ disconnect() {
107
+ send('disconnect').catch(() => {});
108
+ globalThis.removeEventListener?.('message', onWindowMessage);
109
+ iframe.removeEventListener('load', connect);
110
+ try { port?.close(); } catch (_) {}
111
+ port = null; connected = false; posted = false;
112
+ },
113
+ on(type, fn) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn); return () => listeners.get(type).delete(fn); },
114
+ });
115
+ }
116
+
117
+ export function memoryStore(initial = {revision: 0, content: ''}) {
118
+ const state = {...initial};
119
+ return {
120
+ get revision() { return state.revision; },
121
+ get content() { return state.content; },
122
+ async write(requestId, content, baseRevision) {
123
+ if (baseRevision !== null && baseRevision !== undefined && baseRevision !== state.revision) return {conflict: true, currentRevision: state.revision};
124
+ state.content = content;
125
+ state.revision = (typeof state.revision === 'number' ? state.revision : 0) + 1;
126
+ return {revision: state.revision};
127
+ },
128
+ };
129
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "rapier-embed",
3
+ "version": "1.1.2",
4
+ "description": "Put the Rapier editor in your site: one call frames rapier.html?embed=1, connects, loads your document and answers its saves.",
5
+ "keywords": [
6
+ "markdown",
7
+ "editor",
8
+ "iframe",
9
+ "embed",
10
+ "offline",
11
+ "agent",
12
+ "rapier"
13
+ ],
14
+ "author": "Jack Skipworth",
15
+ "license": "MIT",
16
+ "homepage": "https://rapier.website",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/jackskip22/rapier-plugins.git",
20
+ "directory": "npm/rapier-embed"
21
+ },
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "exports": {
27
+ ".": "./embed.mjs"
28
+ },
29
+ "files": [
30
+ "embed.mjs",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "sideEffects": false
35
+ }