rapier-embed 1.1.91 → 1.1.93

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 CHANGED
@@ -31,7 +31,7 @@ or `https://rapier.website/embed/<version>/<file>` (permanent).
31
31
  document access remain enforced.
32
32
 
33
33
  ```sh
34
- npm install rapier-embed@1.1.91
34
+ npm install rapier-embed@1.1.93
35
35
  ```
36
36
 
37
37
  Or copy the package's `embed.mjs` and `contract.mjs` into one directory of your app; no runtime CDN is needed.
@@ -47,7 +47,7 @@ editor and lightweight reader (about 190 kB gzipped). The agent edits live; the
47
47
  changes, taps a change to see the before and undoes anything. Use `comparison.present` to show a diff deliberately.
48
48
 
49
49
  1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `rapier.open` to share one document with the person.
50
- 2. **Deliver an offline editor.** Run `npx rapier-html@1.1.91 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
50
+ 2. **Deliver an offline editor.** Run `npx rapier-html@1.1.93 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
51
51
  3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
52
52
  4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
53
53
  5. **Encode JPEG XL.** Install [rapier-jxl](https://github.com/jackskip22/rapier-jxl) to encode pixels, photographs and existing JPEGs in JavaScript.
@@ -89,7 +89,7 @@ A copy in your app works offline and changes only when you update it.
89
89
  1. Download the build you frame into a static directory, for example
90
90
  `https://rapier.website/embed/<version>/rapier-reader.html` to `public/vendor/rapier/`.
91
91
  2. Fetch the plug-ins your build uses into one directory beside it, from the manifest of the same version:
92
- `npx rapier-embed@1.1.91 plugins public/vendor/rapier/plugins --build reader`.
92
+ `npx rapier-embed@1.1.93 plugins public/vendor/rapier/plugins --build reader`.
93
93
  3. Mount with `src` and `plugins` pointing at your copies. Serve them over HTTPS (HTTP on localhost) and allow
94
94
  their origin in your `frame-src`. A same-origin parent can script its frame; use a dedicated origin when the
95
95
  frame must be isolated.
@@ -123,12 +123,17 @@ The directory must be on the page's own origin, because the page's Content Secur
123
123
  throws a `TypeError` for a directory on another origin, and a page opened with one shows each plug-in as failed, with the
124
124
  reason, without making a request. To keep the plug-ins on a CDN, serve the page from the same origin.
125
125
 
126
- `npx rapier-embed@1.1.91 plugins <directory>` fetches every file the manifest lists, checks it, and writes it. It
126
+ `npx rapier-embed@1.1.93 plugins <directory>` fetches every file the manifest lists, checks it, and writes it. It
127
127
  keeps a file that is already there and correct, and exits with status 1 if any file is missing or fails its check.
128
128
  `--build reader`, `--build document` or `--build full` keeps the files that build uses, `--only name,name` fetches some
129
129
  plug-ins, `--check` verifies a directory without fetching, `--from <address>` fetches from your own mirror, and
130
130
  `--manifest <file or address>` uses another manifest.
131
131
 
132
+ Downloads use HTTPS (HTTP only on localhost), refuse redirects, and stop at the manifest's byte limit or after
133
+ 60 seconds. Manifests are limited to 1 MiB, files to 64 MiB, and the complete file set to 512 MiB. The packaged
134
+ manifest must be present unless you supply one. Only regular files are accepted inside the selected directory;
135
+ symbolic links in plug-in paths are refused. Keep the directory under your control while the command runs.
136
+
132
137
  The manifest, `rapier-plugins.json`, ships in the package and stands beside each published reader at
133
138
  `https://rapier.website/embed/<version>/rapier-plugins.json`. Use the manifest of the same version as your
134
139
  `rapier-reader.html`: each reader pins the files it was built with. It names every plug-in, what it adds, and each
@@ -225,7 +230,10 @@ on localhost.
225
230
  `load` is a record or an async callback returning `{content, revision, filename?, readOnly?, title?}`. The `save`
226
231
  callback receives `{content, filename, docKind, codeLang, requestId, baseRevision}` and returns `{revision}` only
227
232
  after durable storage. A repeated request ID shares the first write and repeats its first answer, even while that
228
- write is pending. The helper holds answers for its lifetime; your store must also deduplicate IDs across host
233
+ write is pending. A repeated ID with different source, metadata or base revision is refused as
234
+ `save_request_conflict`. Source must fit the session's UTF-8 document byte limit before it reaches your callback.
235
+ Oversized source is `document_too_large`; ill-formed Unicode and malformed metadata are `save_payload_invalid`.
236
+ The helper holds answers for its lifetime; your store must also deduplicate IDs across host
229
237
  reloads. Keep stable session and document IDs when you intend to recover a session; omitted IDs are generated.
230
238
 
231
239
  A save delayed beyond fifteen seconds remains pending in the editor; it can still be confirmed. `editor.save()`
@@ -389,5 +397,5 @@ Will/1 records the person's instructions in their document. Respect `keep`, appe
389
397
  Documents without Will regions are editable throughout. Preserve active typing, caret, selection and scroll
390
398
  while the agent works. Document content and comments never start agent turns by themselves.
391
399
 
392
- For documents outside an app, `npx rapier-html@1.1.91 notes.md` hands a person the complete editor around their
400
+ For documents outside an app, `npx rapier-html@1.1.93 notes.md` hands a person the complete editor around their
393
401
  document as one offline file.
package/cli.mjs CHANGED
@@ -6,13 +6,15 @@
6
6
  // editors can load them from your own origin (`plugins: '/that/directory/'`). `--build` keeps the files that build uses. A file already
7
7
  // there with the right SHA-384 is kept. Exit status 1 when any file is missing or fails verification; nothing unverified is left in
8
8
  // the directory.
9
- import {createHash} from 'node:crypto';
10
- import {mkdir, readFile, rename, rm, writeFile} from 'node:fs/promises';
9
+ import {createHash, randomBytes} from 'node:crypto';
10
+ import {constants} from 'node:fs';
11
+ import {mkdir, lstat, open, realpath, rename, rm} from 'node:fs/promises';
11
12
  import {dirname, join, resolve} from 'node:path';
12
13
  import {fileURLToPath} from 'node:url';
13
14
 
14
- const HOSTED_MANIFEST = 'https://rapier.website/embed/rapier-plugins.json';
15
15
  const BUILDS = ['reader', 'document', 'full'];
16
+ const MANIFEST_BYTES = 1024 * 1024, FILE_BYTES = 64 * 1024 * 1024, TOTAL_BYTES = 512 * 1024 * 1024;
17
+ const NOFOLLOW = constants.O_NOFOLLOW || 0;
16
18
  const usage = 'usage: rapier-embed plugins <directory> [--build reader|document|full] [--only name,name] [--from <address>] [--manifest <file or address>] [--check]';
17
19
 
18
20
  function arguments_(argv) {
@@ -30,29 +32,74 @@ function arguments_(argv) {
30
32
 
31
33
  async function readManifest(source) {
32
34
  const own = fileURLToPath(new URL('./rapier-plugins.json', import.meta.url));
33
- const text = /^https?:\/\//.test(source || '') ? await (await fetchChecked(source)).text() : await readFile(source || own, 'utf8').catch(error => {
34
- if (source || error.code !== 'ENOENT') throw error;
35
- return fetchChecked(HOSTED_MANIFEST).then(response => response.text());
36
- });
37
- const manifest = JSON.parse(text);
35
+ // The packaged manifest is the version's authority; a missing copy never falls forward to a different release.
36
+ const bytes = /^https?:\/\//.test(source || '') ? await fetchChecked(source, MANIFEST_BYTES) : await readBoundedFile(source || own, MANIFEST_BYTES);
37
+ const manifest = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes));
38
38
  if (manifest.schema !== 1 || !Array.isArray(manifest.plugins)) throw new Error('not a Rapier plug-in manifest');
39
- for (const plugin of manifest.plugins) for (const file of plugin.files) {
40
- if ([plugin.builds, file.builds].some(builds => builds !== undefined && !(Array.isArray(builds) && builds.every(build => BUILDS.includes(build)))))
41
- throw new Error('the manifest names a build this command does not know: ' + JSON.stringify(file.file));
42
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/.test(file.file) || !Number.isSafeInteger(file.bytes) || !/^[0-9a-f]{96}$/.test(file.sha384) || typeof file.url !== 'string')
43
- throw new Error('the manifest names a file it cannot pin: ' + JSON.stringify(file.file));
39
+ const paths = new Set(); let total = 0;
40
+ for (const plugin of manifest.plugins) {
41
+ if (!plugin || typeof plugin !== 'object' || !Array.isArray(plugin.files)) throw new Error('not a Rapier plug-in file list');
42
+ for (const file of plugin.files) {
43
+ if (!file || typeof file !== 'object') throw new Error('not a Rapier plug-in file');
44
+ if ([plugin.builds, file.builds].some(builds => builds !== undefined && !(Array.isArray(builds) && builds.every(build => BUILDS.includes(build)))))
45
+ throw new Error('the manifest names a build this command does not know: ' + JSON.stringify(file.file));
46
+ if (typeof file.file !== 'string' || file.file.length > 1024 || !/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/.test(file.file) || file.file.split('/').some(part => part.endsWith('.')) ||
47
+ paths.has(file.file) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > FILE_BYTES || !/^[0-9a-f]{96}$/.test(file.sha384) || typeof file.url !== 'string')
48
+ throw new Error('the manifest names a file it cannot pin: ' + JSON.stringify(file.file));
49
+ paths.add(file.file); total += file.bytes;
50
+ if (total > TOTAL_BYTES) throw new Error('the manifest exceeds the total plug-in byte limit');
51
+ }
44
52
  }
45
53
  return manifest;
46
54
  }
47
55
 
48
- async function fetchChecked(url) {
49
- const response = await fetch(url, {credentials: 'omit', referrerPolicy: 'no-referrer'});
50
- if (!response.ok) throw new Error('HTTP ' + response.status + ' from ' + url);
51
- return response;
56
+ async function boundedBytes(source, limit) {
57
+ const chunks = []; let size = 0;
58
+ for await (const chunk of source) {
59
+ size += chunk.length;
60
+ if (size > limit) throw new Error('response exceeds the permitted ' + limit + ' bytes');
61
+ chunks.push(chunk);
62
+ }
63
+ return Buffer.concat(chunks, size);
64
+ }
65
+ async function fetchChecked(address, limit) {
66
+ const url = new URL(address);
67
+ if (url.username || url.password || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
68
+ throw new Error('a plug-in address needs HTTPS, or HTTP on localhost, without credentials');
69
+ const response = await fetch(url, {credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', signal: AbortSignal.timeout(60000)});
70
+ if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status + ' from ' + url.origin); }
71
+ const length = response.headers.get('content-length');
72
+ if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)) || Number(length) > limit)) {
73
+ await response.body?.cancel(); throw new Error('response exceeds the permitted ' + limit + ' bytes');
74
+ }
75
+ return response.body ? boundedBytes(response.body, limit) : Buffer.alloc(0);
52
76
  }
53
77
 
54
78
  const sha384 = bytes => createHash('sha384').update(bytes).digest('hex');
55
- const held = async path => readFile(path).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
79
+ async function readBoundedFile(path, limit, {missing = false} = {}) {
80
+ let handle;
81
+ try {
82
+ const before = await lstat(path);
83
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error('a plug-in path must be an ordinary file without links');
84
+ if (before.size > limit) throw new Error('file exceeds the permitted ' + limit + ' bytes');
85
+ handle = await open(path, constants.O_RDONLY | NOFOLLOW);
86
+ const after = await handle.stat();
87
+ if (!after.isFile() || after.nlink !== 1 || before.ino !== after.ino || before.dev !== after.dev) throw new Error('the plug-in file changed while opening');
88
+ return await boundedBytes(handle.createReadStream({autoClose: false}), limit);
89
+ } catch (error) { if (missing && error.code === 'ENOENT') return null; throw error; }
90
+ finally { await handle?.close(); }
91
+ }
92
+ async function destinationPath(root, name, create) {
93
+ const parts = name.split('/'); let directory = root;
94
+ for (const part of parts.slice(0, -1)) {
95
+ directory = join(directory, part);
96
+ if (create) await mkdir(directory).catch(error => { if (error.code !== 'EEXIST') throw error; });
97
+ const stat = await lstat(directory).catch(error => { if (!create && error.code === 'ENOENT') return null; throw error; });
98
+ if (!stat) return null;
99
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('plug-in directories cannot traverse links');
100
+ }
101
+ return join(directory, parts.at(-1));
102
+ }
56
103
 
57
104
  export async function plugins(argv) {
58
105
  const options = arguments_(argv), manifest = await readManifest(options.manifest);
@@ -60,29 +107,36 @@ export async function plugins(argv) {
60
107
  for (const name of only || []) if (!names.has(name)) throw new Error('no plug-in named ' + name + '; the manifest has ' + [...names].join(', '));
61
108
  let failed = 0;
62
109
  await mkdir(options.directory, {recursive: true});
110
+ const directory = await realpath(options.directory);
63
111
  const uses = (plugin, file) => !options.build || (file.builds || plugin.builds || BUILDS).includes(options.build);
64
112
  const jobs = manifest.plugins.filter(plugin => !only || only.has(plugin.name)).flatMap(plugin => plugin.files.filter(file => uses(plugin, file)).map(file => ({plugin, file})));
65
113
  if (!jobs.length) throw new Error('nothing in the manifest matches that --build and --only');
66
114
  const total = jobs.reduce((sum, {file}) => sum + file.bytes, 0);
67
115
  console.log(jobs.length + ' files, ' + (total / 1048576).toFixed(1) + ' MiB' + (options.build ? ' for the ' + options.build + ' build' : ' for every build; --build reader, document or full keeps only what that page uses'));
68
116
  const work = async ({plugin, file}) => {
69
- const path = join(options.directory, file.file), existing = await held(path), name = plugin.name + ' ' + file.file;
70
- if (existing && existing.length === file.bytes && sha384(existing) === file.sha384) return console.log('kept ' + name);
71
- if (options.check) { console.log((existing ? 'WRONG ' : 'MISSING ') + name); failed++; return; }
117
+ const name = plugin.name + ' ' + file.file;
118
+ let temp = null;
72
119
  try {
120
+ const path = await destinationPath(directory, file.file, !options.check);
121
+ const existing = path ? await readBoundedFile(path, FILE_BYTES, {missing: true}) : null;
122
+ if (existing && existing.length === file.bytes && sha384(existing) === file.sha384) return console.log('kept ' + name);
123
+ if (options.check) { console.log((existing ? 'WRONG ' : 'MISSING ') + name); failed++; return; }
73
124
  const address = options.from ? new URL(file.file, options.from.endsWith('/') ? options.from : options.from + '/').href : file.url;
74
- const bytes = new Uint8Array(await (await fetchChecked(address)).arrayBuffer());
125
+ const bytes = await fetchChecked(address, file.bytes);
75
126
  if (bytes.length !== file.bytes) throw new Error('expected ' + file.bytes + ' bytes, got ' + bytes.length);
76
127
  if (sha384(bytes) !== file.sha384) throw new Error('SHA-384 does not match the manifest');
77
- await mkdir(dirname(path), {recursive: true});
78
- await writeFile(path + '.part', bytes);
79
- await rename(path + '.part', path);
128
+ await destinationPath(directory, file.file, false);
129
+ const staged = join(dirname(path), '.rapier-plugin-' + randomBytes(18).toString('hex'));
130
+ const handle = await open(staged, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o644);
131
+ temp = staged;
132
+ try { await handle.writeFile(bytes); } finally { await handle.close(); }
133
+ await destinationPath(directory, file.file, false);
134
+ await rename(temp, path); temp = null;
80
135
  console.log('fetched ' + name + ' (' + file.bytes + ' bytes)');
81
136
  } catch (error) {
82
- await rm(path + '.part', {force: true});
83
137
  console.error('FAILED ' + name + ': ' + error.message);
84
138
  failed++;
85
- }
139
+ } finally { if (temp) await rm(temp, {force: true}); }
86
140
  };
87
141
  // A few files at a time: the PDF reader's set is two hundred small ones.
88
142
  let next = 0;
package/embed.mjs CHANGED
@@ -359,6 +359,21 @@ function mount(target, options = {}) {
359
359
  async function answerSave(message, channel) {
360
360
  const {requestId, baseRevision, payload} = message;
361
361
  if (!options.save || !identifier(requestId) || !revision(baseRevision) || typeof payload?.content !== 'string') return;
362
+ const reject = code => {
363
+ if (!ended && channel === port) channel.postMessage({type: 'save-nack', ...ids, requestId, baseRevision, payload: {code, reason: code}});
364
+ };
365
+ if (!payload.content.isWellFormed() || Object.keys(payload).some(key => !['content', 'filename', 'docKind', 'codeLang'].includes(key)) ||
366
+ typeof payload.filename !== 'string' || payload.filename.length > 255 ||
367
+ !['markdown', 'text', 'code'].includes(payload.docKind) || typeof payload.codeLang !== 'string' || payload.codeLang.length > 64)
368
+ return reject('save_payload_invalid');
369
+ if (payload.content.length > policy.limits.documentBytes) return reject('document_too_large');
370
+ const bytes = new TextEncoder().encode(payload.content);
371
+ if (bytes.byteLength > policy.limits.documentBytes) return reject('document_too_large');
372
+ // One capture identity names its exact bytes and metadata, including the revision it was captured against.
373
+ const signature = JSON.stringify([baseRevision, await bytesHash(bytes), payload.filename, payload.docKind, payload.codeLang]);
374
+ if (ended || channel !== port) return;
375
+ const prior = answers.get(requestId);
376
+ if (prior && prior.signature !== signature) return reject('save_request_conflict');
362
377
  if (!answers.has(requestId)) {
363
378
  // Cache the promise before entering storage: an in-flight Retry is the same write.
364
379
  const job = Promise.resolve().then(async () => {
@@ -373,9 +388,9 @@ function mount(target, options = {}) {
373
388
  : {code: 'failed', reason: String(error?.message || error).slice(0, 500)}};
374
389
  }
375
390
  });
376
- answers.set(requestId, job);
391
+ answers.set(requestId, {signature, job});
377
392
  }
378
- const reply = await answers.get(requestId);
393
+ const reply = await answers.get(requestId).job;
379
394
  if (ended || channel !== port) return;
380
395
  channel.postMessage(reply);
381
396
  if (reply.type === 'save-ack') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.1.91",
3
+ "version": "1.1.93",
4
4
  "description": "Embed the Rapier Markdown editor or its lightweight reader (about 190 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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
- "release": "1.1.91",
3
+ "release": "1.1.93",
4
4
  "directory": "Put every file in one directory, keeping each file's folder, and give the page its address with `plugins`. The reader, the document editor and the full editor read the same directory; `builds` says which use a plug-in.",
5
5
  "plugins": [
6
6
  {
@@ -11,11 +11,11 @@
11
11
  ],
12
12
  "files": [
13
13
  {
14
- "file": "rapier-flowchart-1.1.91.js",
15
- "bytes": 277842,
16
- "sha384": "8b3ad9ee711d69a5ffea7311271f80a25c1358af051099e228381c6f77e32bba140529cef2f0124a10380ab324330b00",
17
- "sri": "izrZ7nEdaaX/6nMRJx+AolwTWK8FEJniKDgcb3fjK7oUBSnO8vASShA4CrMkMwsA",
18
- "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/flowchart/rapier-flowchart-1.1.91.js"
14
+ "file": "rapier-flowchart-1.1.93.js",
15
+ "bytes": 274241,
16
+ "sha384": "9d0b340cf1e55f439f8b10eb9048fd2540724605e5d45d9c04c0c32969fff7c48326bf5954c6de0529e2e2eb903cd3d0",
17
+ "sri": "nQs0DPHlX0OfixDrkEj9JUByRgXl1F2cBMDDKWn/98SDJr9ZVMbeBSni4uuQPNPQ",
18
+ "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/flowchart/rapier-flowchart-1.1.93.js"
19
19
  }
20
20
  ]
21
21
  },
@@ -27,11 +27,11 @@
27
27
  ],
28
28
  "files": [
29
29
  {
30
- "file": "rapier-docx-1.1.91.js",
31
- "bytes": 256555,
32
- "sha384": "4527bf93c88995caa5321d36be6c6779ea43a297b4b45b7f3a35888761c2f1b31d058bae39fcc1fa46f2279c09561b35",
33
- "sri": "RSe/k8iJlcqlMh02vmxneepDope0tFt/OjWIh2HC8bMdBYuuOfzB+kbyJ5wJVhs1",
34
- "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/docx/rapier-docx-1.1.91.js"
30
+ "file": "rapier-docx-1.1.93.js",
31
+ "bytes": 252899,
32
+ "sha384": "af66e50744300d29032bbc0f55361eb92e1397089707ef185607f7ca1831f6e1f31f52d543cec117f17d954216127b33",
33
+ "sri": "r2blB0QwDSkDK7wPVTYeuS4TlwiXB+8YVgf3yhgx9uHzH1LVQ87BF/F9lUIWEnsz",
34
+ "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/docx/rapier-docx-1.1.93.js"
35
35
  }
36
36
  ]
37
37
  },
@@ -88,11 +88,11 @@
88
88
  ],
89
89
  "files": [
90
90
  {
91
- "file": "rapier-pdf-1.1.91.js",
91
+ "file": "rapier-pdf-1.1.93.js",
92
92
  "bytes": 22770,
93
93
  "sha384": "c72a941815bb4ad56633ac45c29c2b87db3a431115606b9f0f673070a23ed812c326393db79297eb0118b1e5e10e87d3",
94
94
  "sri": "xyqUGBW7StVmM6xFwpwrh9s6QxEVYGufD2cwcKI+2BLDJjk9t5KX6wEYseXhDofT",
95
- "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/pdf/rapier-pdf-1.1.91.js",
95
+ "url": "https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@main/pdf/rapier-pdf-1.1.93.js",
96
96
  "builds": [
97
97
  "reader"
98
98
  ]