@vormio/plugin-sdk 0.0.3 → 0.0.6
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 +105 -8
- package/dist/bridge.d.ts +23 -4
- package/dist/bridge.js +11 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +6 -2
- package/dist/manifest.js +12 -9
- package/dist/node/catalog.d.ts +69 -0
- package/dist/node/catalog.js +240 -0
- package/dist/node/cli.js +72 -17
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +2 -0
- package/dist/node/publish.d.ts +42 -0
- package/dist/node/publish.js +213 -0
- package/dist/package-rules.d.ts +2 -0
- package/dist/package-rules.js +2 -0
- package/dist/runtime.d.ts +16 -9
- package/dist/runtime.js +22 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @vormio/plugin-sdk
|
|
2
2
|
|
|
3
|
-
The manifest schema, runtime helpers and packaging command for writing Vormio plugins. The package is versioned with Vormio-Core (currently 0.0.
|
|
3
|
+
The manifest schema, runtime helpers and packaging command for writing Vormio plugins, plus the commands that publish a signed plugin catalog. The package is versioned with Vormio-Core (currently 0.0.6) and published to npm under the MIT license: `npm install @vormio/plugin-sdk@0.0.6`.
|
|
4
4
|
|
|
5
|
-
Plugin contract version: `apiVersion:
|
|
5
|
+
Plugin contract version: `apiVersion: 2`. During development (0.0.x) there is no cross-version compatibility promise: the host loads only plugins with the same `apiVersion` as its own.
|
|
6
6
|
|
|
7
7
|
## What a plugin can do
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ A plugin is a folder: a `manifest.json` at the root plus the built static files.
|
|
|
28
28
|
|
|
29
29
|
```json
|
|
30
30
|
{
|
|
31
|
-
"apiVersion":
|
|
31
|
+
"apiVersion": 2,
|
|
32
32
|
"id": "acme.model-viewer",
|
|
33
33
|
"version": "0.1.0",
|
|
34
34
|
"name": "Model Viewer",
|
|
@@ -63,16 +63,23 @@ A plugin is a folder: a `manifest.json` at the root plus the built static files.
|
|
|
63
63
|
|
|
64
64
|
**Allowed files**: `html js mjs css json wasm png jpg jpeg webp svg woff2 txt md`. Links, `..` paths, Windows reserved names and names that differ only by case are not allowed.
|
|
65
65
|
|
|
66
|
-
**Limits**: at most 1,024 files, 16 MiB per file and 64 MiB in total.
|
|
66
|
+
**Limits**: at most 1,024 files, 16 MiB per file and 64 MiB in total; the `.vormioplugin` file itself is at most 96 MiB.
|
|
67
67
|
|
|
68
|
-
**
|
|
68
|
+
**Grants**: a library records what the user approved for each extension point (analyzers included): its ID, `formats` and `companionFormats`. A version that reads no more than that keeps running without asking again, including:
|
|
69
|
+
|
|
70
|
+
- an upgrade that changes only code, name or version
|
|
71
|
+
- an upgrade that reads less
|
|
72
|
+
- a rollback
|
|
73
|
+
|
|
74
|
+
Reading more formats or companion formats, or adding an extension point, requires the user to enable the plugin again, and that approval is added to what was approved before.
|
|
69
75
|
|
|
70
76
|
## Runtime API
|
|
71
77
|
|
|
72
78
|
```ts
|
|
73
79
|
import { analyzer, readCompanion, readInput, thumbnail, viewer } from '@vormio/plugin-sdk'
|
|
74
80
|
|
|
75
|
-
// Viewer: called first and on every file change. Returning means ready; throwing reports a failure
|
|
81
|
+
// Viewer: called first and on every file change. Returning means ready; throwing reports a failure;
|
|
82
|
+
// returning notApplicable() hands the file to the next viewer (see below).
|
|
76
83
|
viewer(async (input, signal) => {
|
|
77
84
|
const bytes = await readInput(input)
|
|
78
85
|
const texture = await readCompanion(input, 'textures/base.png')
|
|
@@ -108,12 +115,36 @@ analyzer(async (input) => {
|
|
|
108
115
|
| `changed` | The file changed on disk and no longer matches the library's record | 409 |
|
|
109
116
|
| `unavailable` | Any other reason, such as an offline linked folder or an expired binding | Other |
|
|
110
117
|
|
|
111
|
-
- Pages that do not use these helpers can call `window.vormio` directly; see the `PluginBridge` type in `@vormio/plugin-sdk/bridge`. A thumbnail handler returns the PNG as an `ArrayBuffer`, and an analyzer handler (`onAnalyzeRequest`) returns the composition report
|
|
118
|
+
- Pages that do not use these helpers can call `window.vormio` directly; see the `PluginBridge` type in `@vormio/plugin-sdk/bridge`. A thumbnail handler returns the PNG as an `ArrayBuffer`, and an analyzer handler (`onAnalyzeRequest`) returns the composition report; either can return `{ notApplicable: true }` instead, and a viewer calls `notApplicable(input)`.
|
|
112
119
|
- While a plugin view has focus, unmodified Esc, ← and → are handled by the host (back to the grid, previous, next); the plugin does not receive these keys.
|
|
113
120
|
|
|
121
|
+
## Files that are not your format
|
|
122
|
+
|
|
123
|
+
The host chooses plugins by extension only. When a format you declare is shared with other tools, such as `json`, every file with that extension comes to you. Return `notApplicable()` for the files that are not yours:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { analyzer, notApplicable, readInput, thumbnail, viewer } from '@vormio/plugin-sdk'
|
|
127
|
+
|
|
128
|
+
analyzer(async (input) => {
|
|
129
|
+
const data = JSON.parse(new TextDecoder().decode(await readInput(input)))
|
|
130
|
+
if (!isSkeleton(data)) return notApplicable()
|
|
131
|
+
return analyzeSkeleton(data)
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`thumbnail()` and `viewer()` accept the same answer.
|
|
136
|
+
|
|
137
|
+
- It is not a failure. The host records no reason and shows nothing for it:
|
|
138
|
+
- an analyzer's file gets no composition section;
|
|
139
|
+
- thumbnails and viewers move on to the next enabled plugin for the format, then to the built-in preview or the type icon.
|
|
140
|
+
- The host asks each extension point about each file version once. It asks again when the file changes or your plugin's code changes, so a plugin loaded from a development folder is asked again after every reload.
|
|
141
|
+
- Decide from the file alone; companion files read before answering are not tracked. Decide from the start of the file when you can (the input supports Range requests): a viewer's user waits for the answer before seeing the built-in preview.
|
|
142
|
+
- A file that is your format but has a problem should throw, or report diagnostics, so the user sees why.
|
|
143
|
+
- When several enabled plugins declare a format, the host asks them in order of plugin ID. Thumbnails and viewers ask the next one after a `notApplicable()`; analyzers ask only the first.
|
|
144
|
+
|
|
114
145
|
## Analyzers
|
|
115
146
|
|
|
116
|
-
Once a plugin is enabled in a library, the host analyzes its declared formats in the background. Matching files join the queue when linking and scanning find new files or versions, when the plugin is enabled, and when the plugin's code changes. The file whose details the user is viewing goes first, and the user can also re-analyze manually. At most 2 analyses run at once, each with a 30-second timeout. A thrown error, a timeout or an invalid report counts as a failed analysis and shows the reason; the same plugin code is not retried on the same file version.
|
|
147
|
+
Once a plugin is enabled in a library, the host analyzes its declared formats in the background. Matching files join the queue when linking and scanning find new files or versions, when the plugin is enabled, and when the plugin's code changes. The file whose details the user is viewing goes first, and the user can also re-analyze manually. At most 2 analyses run at once, each with a 30-second timeout. A thrown error, a timeout or an invalid report counts as a failed analysis and shows the reason; the same plugin code is not retried on the same file version. For files that are not your format, return `notApplicable()` (see [Files that are not your format](#files-that-are-not-your-format)).
|
|
117
148
|
|
|
118
149
|
A report describes one version of the analyzed file:
|
|
119
150
|
|
|
@@ -158,3 +189,69 @@ npx vormio-plugin pack <build-output-folder> <output-file.vormioplugin>
|
|
|
158
189
|
The command validates the manifest and every file, then writes a zip-format `.vormioplugin`. It never overwrites an existing output file.
|
|
159
190
|
|
|
160
191
|
During development, open the **插件** (Plugins) page in Vormio, choose **添加开发目录…** (Add development folder…) and point it at the build output folder. The host watches the folder: every change is validated again, and a valid build reloads the plugin. If validation fails, the last valid version stays loaded and the error is shown on the plugin card. Vormio never runs build commands.
|
|
192
|
+
|
|
193
|
+
## Plugin catalogs
|
|
194
|
+
|
|
195
|
+
A plugin catalog is a signed `catalog.json` plus the plugin packages it lists, on static hosting. Vormio installs and updates plugins from it. Publishers produce one with two commands:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npx vormio-plugin keygen <key-path-prefix>
|
|
199
|
+
npx vormio-plugin catalog <release-config.json> --key <private-key.pem> --out <new-folder>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- `keygen` generates an Ed25519 key pair: `<prefix>.private.pem` (PKCS#8) and `<prefix>.public.txt` (the base64 of the 32-byte public key; hosts build this line in). It refuses to write inside a Git working tree and never overwrites a file. The private key stays on the publisher's machine: never commit or upload it.
|
|
203
|
+
- The release configuration lists every package in the new catalog, relative to the configuration file:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"previous": "published/3/catalog.json",
|
|
208
|
+
"packages": ["artifacts/model-viewer-0.2.0.vormioplugin"]
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`previous` is the catalog currently online. The new catalog's sequence is its sequence plus 1, and it must be signed with the same key. A first publication has no previous catalog and uses `"initialSequence": 1` instead.
|
|
213
|
+
|
|
214
|
+
- `catalog` does the following:
|
|
215
|
+
- Validates every package with the rules a host applies when installing it.
|
|
216
|
+
- Copies each package to `packages/<plugin ID>-<version>-<first 12 hex digits of its SHA-256>.vormioplugin`.
|
|
217
|
+
- Writes and signs `catalog.json`, then verifies it with the public key.
|
|
218
|
+
|
|
219
|
+
The output folder must be new or empty. If any check fails, nothing is written.
|
|
220
|
+
|
|
221
|
+
- Upload `packages/` first and `catalog.json` last. Do not cache `catalog.json`. Package file names change with their content, so packages can be cached for a long time.
|
|
222
|
+
|
|
223
|
+
**Format**: `catalog.json` is an envelope `{ format: 1, keyId, payload, signature }`:
|
|
224
|
+
|
|
225
|
+
- `payload` is the base64 of the content's UTF-8 JSON.
|
|
226
|
+
- `signature` is the Ed25519 signature of those bytes.
|
|
227
|
+
- `keyId` is the first 16 hex digits of the public key's SHA-256.
|
|
228
|
+
|
|
229
|
+
The content:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
interface CatalogPayload {
|
|
233
|
+
sequence: number // Grows by 1 per publication; hosts refuse a sequence lower than one they have seen
|
|
234
|
+
publishedAt: string // ISO 8601, UTC
|
|
235
|
+
plugins: {
|
|
236
|
+
id: string
|
|
237
|
+
releases: {
|
|
238
|
+
manifest: object // The package's manifest.json, as is
|
|
239
|
+
file: string // packages/<name>.vormioplugin, relative to catalog.json
|
|
240
|
+
bytes: number
|
|
241
|
+
sha256: string // SHA-256 of the whole package file, lowercase hex
|
|
242
|
+
}[]
|
|
243
|
+
}[]
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- Limits:
|
|
248
|
+
- `catalog.json` is at most 1 MiB.
|
|
249
|
+
- At most 256 plugins, with at most 8 releases each.
|
|
250
|
+
- Package files are at most 96 MiB.
|
|
251
|
+
|
|
252
|
+
Plugin IDs, and the versions of one plugin, must not repeat. `manifest.id` must match the plugin ID.
|
|
253
|
+
|
|
254
|
+
- One plugin can list releases for hosts of different `apiVersion`s side by side. A host installs only releases with its own `apiVersion` whose manifest passes its own validation; it shows the rest as needing a newer Vormio.
|
|
255
|
+
- Hosts verify the signature before reading the content. A downloaded package must match `bytes` and `sha256`, then pass the rules of a local install, and its manifest must equal the one in the catalog.
|
|
256
|
+
|
|
257
|
+
`@vormio/plugin-sdk/node` exports the same functionality: `verifyCatalog`, `signCatalog`, `writeCatalog`, `writeCatalogKeys`, `releaseManifest` and `compareVersions`.
|
package/dist/bridge.d.ts
CHANGED
|
@@ -54,6 +54,16 @@ export interface CompositionReport {
|
|
|
54
54
|
/** The analyzer could only finish part of the file. */
|
|
55
55
|
partial?: boolean;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A handler's answer for a file that is not its format, such as a JSON file that is no skeleton.
|
|
59
|
+
* It is not a failure: the host shows nothing for it and does not ask again about this version of
|
|
60
|
+
* the file until the plugin's code changes.
|
|
61
|
+
*/
|
|
62
|
+
export interface NotApplicable {
|
|
63
|
+
notApplicable: true;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a handler's answer is exactly `{ notApplicable: true }`. */
|
|
66
|
+
export declare function isNotApplicable(value: unknown): value is NotApplicable;
|
|
57
67
|
/** The frozen object the host's plugin preload exposes as `window.vormio`. */
|
|
58
68
|
export interface PluginBridge {
|
|
59
69
|
/** The viewer's current input. */
|
|
@@ -64,13 +74,22 @@ export interface PluginBridge {
|
|
|
64
74
|
ready(input: PluginInput): void;
|
|
65
75
|
/** The viewer cannot show `input`; the host shows `message` as plain text. */
|
|
66
76
|
fail(input: PluginInput, message: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* `input` is not the viewer's format; the host moves on to the next viewer or the built-in
|
|
79
|
+
* preview.
|
|
80
|
+
*/
|
|
81
|
+
notApplicable(input: PluginInput): void;
|
|
67
82
|
/**
|
|
68
83
|
* Thumbnail pages: handles each request from the host and returns PNG bytes no larger than
|
|
69
|
-
* `maxEdge` on either side
|
|
84
|
+
* `maxEdge` on either side, or `{ notApplicable: true }`. (Blobs cannot cross the bridge;
|
|
85
|
+
* `thumbnail()` converts them.)
|
|
70
86
|
*/
|
|
71
87
|
onThumbnailRequest(handler: (input: PluginInput, size: {
|
|
72
88
|
maxEdge: number;
|
|
73
|
-
}) => Promise<ArrayBuffer>): void;
|
|
74
|
-
/**
|
|
75
|
-
|
|
89
|
+
}) => Promise<ArrayBuffer | NotApplicable>): void;
|
|
90
|
+
/**
|
|
91
|
+
* Analyzer pages: handles each request from the host and returns the composition report, or
|
|
92
|
+
* `{ notApplicable: true }`.
|
|
93
|
+
*/
|
|
94
|
+
onAnalyzeRequest(handler: (input: PluginInput) => Promise<CompositionReport | NotApplicable>): void;
|
|
76
95
|
}
|
package/dist/bridge.js
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
|
-
// Types shared by plugin pages and the host's plugin preload
|
|
2
|
-
|
|
1
|
+
// Types shared by plugin pages and the host's plugin preload, and one check free of schemas that the
|
|
2
|
+
// preload uses. No DOM types, so Node code can use them.
|
|
3
|
+
/** Whether a handler's answer is exactly `{ notApplicable: true }`. */
|
|
4
|
+
export function isNotApplicable(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
6
|
+
return false;
|
|
7
|
+
const keys = Object.keys(value);
|
|
8
|
+
return (keys.length === 1 &&
|
|
9
|
+
keys[0] === 'notApplicable' &&
|
|
10
|
+
value.notApplicable === true);
|
|
11
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { contributionsOf, manifestSchema, missingEntries, parseManifest, permiss
|
|
|
2
2
|
export { checkPackageFiles, checkPackagePath, packageExtensions, packageLimits, type PackageFile, } from './package-rules.ts';
|
|
3
3
|
export { compositionReportSchema, parseReport, reportLimits, type ReportResult } from './report.ts';
|
|
4
4
|
export type { ReportDiagnostic, ReportReference, ReportSubresource } from './bridge.ts';
|
|
5
|
-
export { analyzer, bridge, readCompanion, readInput, thumbnail, viewer, type CompanionResult, type CompanionStatus, type CompositionReport, type PluginBridge, type PluginInput, } from './runtime.ts';
|
|
5
|
+
export { analyzer, bridge, isNotApplicable, notApplicable, readCompanion, readInput, thumbnail, viewer, type CompanionResult, type CompanionStatus, type CompositionReport, type NotApplicable, type PluginBridge, type PluginInput, } from './runtime.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, } from "./manifest.js";
|
|
2
2
|
export { checkPackageFiles, checkPackagePath, packageExtensions, packageLimits, } from "./package-rules.js";
|
|
3
3
|
export { compositionReportSchema, parseReport, reportLimits } from "./report.js";
|
|
4
|
-
export { analyzer, bridge, readCompanion, readInput, thumbnail, viewer, } from "./runtime.js";
|
|
4
|
+
export { analyzer, bridge, isNotApplicable, notApplicable, readCompanion, readInput, thumbnail, viewer, } from "./runtime.js";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
/** The plugin contract version this SDK describes. Hosts load only plugins with this version. */
|
|
3
|
-
export declare const pluginApiVersion =
|
|
3
|
+
export declare const pluginApiVersion = 2;
|
|
4
4
|
declare const contributionSchema: z.ZodObject<{
|
|
5
5
|
id: z.ZodString;
|
|
6
6
|
entry: z.ZodString;
|
|
@@ -8,8 +8,12 @@ declare const contributionSchema: z.ZodObject<{
|
|
|
8
8
|
companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
9
9
|
}, z.core.$strict>;
|
|
10
10
|
export type Contribution = z.output<typeof contributionSchema>;
|
|
11
|
+
/** `publisher.name`. The `vormio` publisher is reserved for the host. */
|
|
12
|
+
export declare const pluginIdSchema: z.ZodString;
|
|
13
|
+
/** A semantic version without build metadata. */
|
|
14
|
+
export declare const versionSchema: z.ZodString;
|
|
11
15
|
export declare const manifestSchema: z.ZodObject<{
|
|
12
|
-
apiVersion: z.ZodLiteral<
|
|
16
|
+
apiVersion: z.ZodLiteral<2>;
|
|
13
17
|
id: z.ZodString;
|
|
14
18
|
version: z.ZodString;
|
|
15
19
|
name: z.ZodString;
|
package/dist/manifest.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { checkPackagePath } from "./package-rules.js";
|
|
3
3
|
/** The plugin contract version this SDK describes. Hosts load only plugins with this version. */
|
|
4
|
-
export const pluginApiVersion =
|
|
4
|
+
export const pluginApiVersion = 2;
|
|
5
5
|
const unique = (values) => new Set(values).size === values.length;
|
|
6
6
|
const extension = z.string().regex(/^[a-z0-9]{1,16}$/, 'Use a lower-case extension without a dot');
|
|
7
7
|
const extensions = (min) => z.array(extension).min(min).max(64).refine(unique, 'Extensions must not repeat');
|
|
@@ -22,16 +22,19 @@ const contributionList = z
|
|
|
22
22
|
.max(8)
|
|
23
23
|
.default([])
|
|
24
24
|
.refine((items) => unique(items.map((item) => item.id)), 'Contribution IDs must not repeat');
|
|
25
|
+
/** `publisher.name`. The `vormio` publisher is reserved for the host. */
|
|
26
|
+
export const pluginIdSchema = z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,39}\.[a-z0-9][a-z0-9-]{0,39}$/, 'Use publisher.name')
|
|
29
|
+
.refine((id) => !id.startsWith('vormio.'), 'The vormio publisher is reserved');
|
|
30
|
+
/** A semantic version without build metadata. */
|
|
31
|
+
export const versionSchema = z
|
|
32
|
+
.string()
|
|
33
|
+
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, 'Use a semantic version');
|
|
25
34
|
export const manifestSchema = z.strictObject({
|
|
26
35
|
apiVersion: z.literal(pluginApiVersion),
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.string()
|
|
30
|
-
.regex(/^[a-z0-9][a-z0-9-]{0,39}\.[a-z0-9][a-z0-9-]{0,39}$/, 'Use publisher.name')
|
|
31
|
-
.refine((id) => !id.startsWith('vormio.'), 'The vormio publisher is reserved'),
|
|
32
|
-
version: z
|
|
33
|
-
.string()
|
|
34
|
-
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, 'Use a semantic version'),
|
|
36
|
+
id: pluginIdSchema,
|
|
37
|
+
version: versionSchema,
|
|
35
38
|
name: z.string().trim().min(1).max(80),
|
|
36
39
|
description: z.string().max(400).default(''),
|
|
37
40
|
contributions: z
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type ManifestResult } from '../manifest.ts';
|
|
3
|
+
/** Limits for a signed plugin catalog. */
|
|
4
|
+
export declare const catalogLimits: {
|
|
5
|
+
readonly maxCatalogBytes: number;
|
|
6
|
+
readonly maxPlugins: 256;
|
|
7
|
+
readonly maxReleasesPerPlugin: 8;
|
|
8
|
+
};
|
|
9
|
+
/** Which check a catalog failed: its shape, the key it was signed with, or the signature itself. */
|
|
10
|
+
export type CatalogErrorCode = 'format' | 'key' | 'signature';
|
|
11
|
+
export declare class CatalogError extends Error {
|
|
12
|
+
readonly code: CatalogErrorCode;
|
|
13
|
+
readonly problems: readonly string[];
|
|
14
|
+
constructor(code: CatalogErrorCode, problems: readonly string[]);
|
|
15
|
+
}
|
|
16
|
+
declare const payloadSchema: z.ZodObject<{
|
|
17
|
+
sequence: z.ZodNumber;
|
|
18
|
+
publishedAt: z.ZodISODateTime;
|
|
19
|
+
plugins: z.ZodArray<z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
releases: z.ZodArray<z.ZodObject<{
|
|
22
|
+
manifest: z.ZodObject<{
|
|
23
|
+
apiVersion: z.ZodNumber;
|
|
24
|
+
id: z.ZodString;
|
|
25
|
+
version: z.ZodString;
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
description: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$loose>;
|
|
29
|
+
file: z.ZodString;
|
|
30
|
+
bytes: z.ZodNumber;
|
|
31
|
+
sha256: z.ZodString;
|
|
32
|
+
}, z.core.$strict>>;
|
|
33
|
+
}, z.core.$strict>>;
|
|
34
|
+
}, z.core.$strict>;
|
|
35
|
+
/** What `signCatalog` signs. */
|
|
36
|
+
export type CatalogPayload = z.input<typeof payloadSchema>;
|
|
37
|
+
/** A catalog whose signature has been verified. */
|
|
38
|
+
export type Catalog = z.output<typeof payloadSchema> & {
|
|
39
|
+
keyId: string;
|
|
40
|
+
};
|
|
41
|
+
export type CatalogPlugin = Catalog['plugins'][number];
|
|
42
|
+
export type CatalogRelease = CatalogPlugin['releases'][number];
|
|
43
|
+
/** The first 16 hexadecimal digits of the public key's SHA-256; catalogs name their key by it. */
|
|
44
|
+
export declare function catalogKeyId(publicKey: string): string;
|
|
45
|
+
/** The public key that belongs to a PEM private key. */
|
|
46
|
+
export declare function catalogPublicKey(privateKey: string): string;
|
|
47
|
+
export interface CatalogKeys {
|
|
48
|
+
/** Base64 of the raw key; hosts build it in. */
|
|
49
|
+
publicKey: string;
|
|
50
|
+
/** PKCS#8 PEM. */
|
|
51
|
+
privateKey: string;
|
|
52
|
+
keyId: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function generateCatalogKeys(): CatalogKeys;
|
|
55
|
+
/** Validates a payload and returns the signed `catalog.json` bytes. */
|
|
56
|
+
export declare function signCatalog(payload: CatalogPayload, privateKey: string): Buffer;
|
|
57
|
+
/**
|
|
58
|
+
* Checks `catalog.json` against a public key: shape of the envelope, key ID, signature, and only
|
|
59
|
+
* then the payload. Throws `CatalogError` at the first check that fails.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyCatalog(bytes: Uint8Array, publicKey: string): Catalog;
|
|
62
|
+
/**
|
|
63
|
+
* The release's manifest when this SDK's hosts can install it: same apiVersion, and valid under
|
|
64
|
+
* this SDK's manifest schema. Otherwise the reasons, typically that the release needs a newer host.
|
|
65
|
+
*/
|
|
66
|
+
export declare function releaseManifest(release: CatalogRelease): ManifestResult;
|
|
67
|
+
/** Orders two semantic versions by precedence: negative, zero or positive, like a sort callback. */
|
|
68
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { parseManifest, pluginApiVersion, pluginIdSchema, versionSchema, } from "../manifest.js";
|
|
4
|
+
import { packageLimits } from "../package-rules.js";
|
|
5
|
+
/** Limits for a signed plugin catalog. */
|
|
6
|
+
export const catalogLimits = {
|
|
7
|
+
maxCatalogBytes: 1024 * 1024,
|
|
8
|
+
maxPlugins: 256,
|
|
9
|
+
maxReleasesPerPlugin: 8,
|
|
10
|
+
};
|
|
11
|
+
export class CatalogError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
problems;
|
|
14
|
+
constructor(code, problems) {
|
|
15
|
+
super(problems.join('\n'));
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.problems = problems;
|
|
18
|
+
this.name = 'CatalogError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The manifest fields every host reads, whatever the release's apiVersion. The rest of the manifest
|
|
23
|
+
* is kept as is: a host parses it fully only for releases of its own apiVersion (`releaseManifest`).
|
|
24
|
+
*/
|
|
25
|
+
const releaseManifestSchema = z.looseObject({
|
|
26
|
+
apiVersion: z.number().int().min(1),
|
|
27
|
+
id: pluginIdSchema,
|
|
28
|
+
version: versionSchema,
|
|
29
|
+
name: z.string().trim().min(1).max(80),
|
|
30
|
+
description: z.string().max(400).optional(),
|
|
31
|
+
});
|
|
32
|
+
const releaseSchema = z.strictObject({
|
|
33
|
+
/** The package's `manifest.json`. */
|
|
34
|
+
manifest: releaseManifestSchema,
|
|
35
|
+
/** The package file, relative to the catalog: always directly inside `packages/`. */
|
|
36
|
+
file: z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^packages\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.vormioplugin$/, 'Use packages/<name>.vormioplugin'),
|
|
39
|
+
bytes: z.number().int().min(1).max(packageLimits.maxArchiveBytes),
|
|
40
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/, 'Use lower-case hexadecimal'),
|
|
41
|
+
});
|
|
42
|
+
const payloadSchema = z
|
|
43
|
+
.strictObject({
|
|
44
|
+
/** Grows with every publication; hosts refuse a catalog older than one they have seen. */
|
|
45
|
+
sequence: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
46
|
+
publishedAt: z.iso.datetime(),
|
|
47
|
+
plugins: z
|
|
48
|
+
.array(z.strictObject({
|
|
49
|
+
id: pluginIdSchema,
|
|
50
|
+
releases: z.array(releaseSchema).min(1).max(catalogLimits.maxReleasesPerPlugin),
|
|
51
|
+
}))
|
|
52
|
+
.max(catalogLimits.maxPlugins),
|
|
53
|
+
})
|
|
54
|
+
.superRefine((payload, context) => {
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
const files = new Set();
|
|
57
|
+
payload.plugins.forEach((plugin, index) => {
|
|
58
|
+
const issue = (path, message) => context.addIssue({ code: 'custom', path: ['plugins', index, ...path], message });
|
|
59
|
+
if (ids.has(plugin.id))
|
|
60
|
+
issue(['id'], 'Plugin IDs must not repeat');
|
|
61
|
+
ids.add(plugin.id);
|
|
62
|
+
const versions = new Set();
|
|
63
|
+
plugin.releases.forEach((release, at) => {
|
|
64
|
+
if (release.manifest.id !== plugin.id)
|
|
65
|
+
issue(['releases', at, 'manifest', 'id'], 'The manifest ID must match the plugin ID');
|
|
66
|
+
if (versions.has(release.manifest.version))
|
|
67
|
+
issue(['releases', at, 'manifest', 'version'], 'Versions must not repeat');
|
|
68
|
+
versions.add(release.manifest.version);
|
|
69
|
+
const file = release.file.toLowerCase();
|
|
70
|
+
if (files.has(file))
|
|
71
|
+
issue(['releases', at, 'file'], 'Files must not repeat');
|
|
72
|
+
files.add(file);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const envelopeSchema = z.strictObject({
|
|
77
|
+
format: z.literal(1),
|
|
78
|
+
keyId: z.string().regex(/^[0-9a-f]{16}$/),
|
|
79
|
+
/** The payload's UTF-8 JSON, base64; the signature covers exactly these bytes. */
|
|
80
|
+
payload: z.string().min(1),
|
|
81
|
+
signature: z.string().min(1),
|
|
82
|
+
});
|
|
83
|
+
function problemsOf(error) {
|
|
84
|
+
return error.issues.map((issue) => `${issue.path.length ? issue.path.join('.') : 'catalog'}: ${issue.message}`);
|
|
85
|
+
}
|
|
86
|
+
/** Decodes canonical base64 only, so that one envelope has exactly one meaning. */
|
|
87
|
+
function decodeBase64(text) {
|
|
88
|
+
const bytes = Buffer.from(text, 'base64');
|
|
89
|
+
return bytes.length > 0 && bytes.toString('base64') === text ? bytes : undefined;
|
|
90
|
+
}
|
|
91
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
92
|
+
function parseJson(bytes, what) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(utf8.decode(bytes));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new CatalogError('format', [`${what} is not valid UTF-8 JSON`]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Keys ------------------------------------------------------------------------------------
|
|
101
|
+
// A public key is written as the base64 of its 32 raw Ed25519 bytes, short enough to build into the
|
|
102
|
+
// app. Private keys are PKCS#8 PEM files that never leave the publisher's machine.
|
|
103
|
+
function rawPublicKey(publicKey) {
|
|
104
|
+
const bytes = decodeBase64(publicKey.trim());
|
|
105
|
+
if (bytes?.length !== 32)
|
|
106
|
+
throw new CatalogError('key', ['A public key is 32 raw Ed25519 bytes in base64']);
|
|
107
|
+
return bytes;
|
|
108
|
+
}
|
|
109
|
+
function publicKeyObject(publicKey) {
|
|
110
|
+
const x = rawPublicKey(publicKey).toString('base64url');
|
|
111
|
+
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
|
|
112
|
+
}
|
|
113
|
+
function publicKeyText(key) {
|
|
114
|
+
return Buffer.from(key.export({ format: 'jwk' }).x, 'base64url').toString('base64');
|
|
115
|
+
}
|
|
116
|
+
function privateKeyObject(privateKey) {
|
|
117
|
+
let key;
|
|
118
|
+
try {
|
|
119
|
+
key = createPrivateKey(privateKey);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CatalogError('key', ['The private key is not a PEM private key']);
|
|
123
|
+
}
|
|
124
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
125
|
+
throw new CatalogError('key', ['The private key is not an Ed25519 key']);
|
|
126
|
+
return key;
|
|
127
|
+
}
|
|
128
|
+
/** The first 16 hexadecimal digits of the public key's SHA-256; catalogs name their key by it. */
|
|
129
|
+
export function catalogKeyId(publicKey) {
|
|
130
|
+
return createHash('sha256').update(rawPublicKey(publicKey)).digest('hex').slice(0, 16);
|
|
131
|
+
}
|
|
132
|
+
/** The public key that belongs to a PEM private key. */
|
|
133
|
+
export function catalogPublicKey(privateKey) {
|
|
134
|
+
return publicKeyText(createPublicKey(privateKeyObject(privateKey)));
|
|
135
|
+
}
|
|
136
|
+
export function generateCatalogKeys() {
|
|
137
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
138
|
+
const text = publicKeyText(publicKey);
|
|
139
|
+
return {
|
|
140
|
+
publicKey: text,
|
|
141
|
+
privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
|
|
142
|
+
keyId: catalogKeyId(text),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// --- Signing and verification ----------------------------------------------------------------
|
|
146
|
+
/** Validates a payload and returns the signed `catalog.json` bytes. */
|
|
147
|
+
export function signCatalog(payload, privateKey) {
|
|
148
|
+
const result = payloadSchema.safeParse(payload);
|
|
149
|
+
if (!result.success)
|
|
150
|
+
throw new CatalogError('format', problemsOf(result.error));
|
|
151
|
+
const key = privateKeyObject(privateKey);
|
|
152
|
+
const bytes = Buffer.from(JSON.stringify(payload));
|
|
153
|
+
const envelope = {
|
|
154
|
+
format: 1,
|
|
155
|
+
keyId: catalogKeyId(publicKeyText(createPublicKey(key))),
|
|
156
|
+
payload: bytes.toString('base64'),
|
|
157
|
+
signature: sign(null, bytes, key).toString('base64'),
|
|
158
|
+
};
|
|
159
|
+
const text = Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
160
|
+
if (text.length > catalogLimits.maxCatalogBytes)
|
|
161
|
+
throw new CatalogError('format', ['The catalog is larger than 1 MiB']);
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Checks `catalog.json` against a public key: shape of the envelope, key ID, signature, and only
|
|
166
|
+
* then the payload. Throws `CatalogError` at the first check that fails.
|
|
167
|
+
*/
|
|
168
|
+
export function verifyCatalog(bytes, publicKey) {
|
|
169
|
+
if (bytes.length > catalogLimits.maxCatalogBytes)
|
|
170
|
+
throw new CatalogError('format', ['The catalog is larger than 1 MiB']);
|
|
171
|
+
const envelope = envelopeSchema.safeParse(parseJson(bytes, 'The catalog'));
|
|
172
|
+
if (!envelope.success)
|
|
173
|
+
throw new CatalogError('format', problemsOf(envelope.error));
|
|
174
|
+
const keyId = catalogKeyId(publicKey);
|
|
175
|
+
if (envelope.data.keyId !== keyId)
|
|
176
|
+
throw new CatalogError('key', [
|
|
177
|
+
`The catalog is signed with key ${envelope.data.keyId}, not ${keyId}`,
|
|
178
|
+
]);
|
|
179
|
+
const payload = decodeBase64(envelope.data.payload);
|
|
180
|
+
const signature = decodeBase64(envelope.data.signature);
|
|
181
|
+
if (!payload || signature?.length !== 64)
|
|
182
|
+
throw new CatalogError('format', ['The payload or signature is not canonical base64']);
|
|
183
|
+
if (!verify(null, payload, publicKeyObject(publicKey), signature))
|
|
184
|
+
throw new CatalogError('signature', ['The signature does not match the catalog']);
|
|
185
|
+
const result = payloadSchema.safeParse(parseJson(payload, 'The payload'));
|
|
186
|
+
if (!result.success)
|
|
187
|
+
throw new CatalogError('format', problemsOf(result.error));
|
|
188
|
+
return { keyId, ...result.data };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The release's manifest when this SDK's hosts can install it: same apiVersion, and valid under
|
|
192
|
+
* this SDK's manifest schema. Otherwise the reasons, typically that the release needs a newer host.
|
|
193
|
+
*/
|
|
194
|
+
export function releaseManifest(release) {
|
|
195
|
+
if (release.manifest.apiVersion !== pluginApiVersion)
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
problems: [
|
|
199
|
+
`apiVersion ${release.manifest.apiVersion}; this version supports ${pluginApiVersion}`,
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
return parseManifest(release.manifest);
|
|
203
|
+
}
|
|
204
|
+
// --- Versions --------------------------------------------------------------------------------
|
|
205
|
+
function versionParts(version) {
|
|
206
|
+
const dash = version.indexOf('-');
|
|
207
|
+
const core = dash < 0 ? version : version.slice(0, dash);
|
|
208
|
+
return {
|
|
209
|
+
core: core.split('.').map(Number),
|
|
210
|
+
prerelease: dash < 0 ? [] : version.slice(dash + 1).split('.'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Orders two semantic versions by precedence: negative, zero or positive, like a sort callback. */
|
|
214
|
+
export function compareVersions(a, b) {
|
|
215
|
+
const left = versionParts(a);
|
|
216
|
+
const right = versionParts(b);
|
|
217
|
+
for (let at = 0; at < 3; at++) {
|
|
218
|
+
const difference = (left.core[at] ?? 0) - (right.core[at] ?? 0);
|
|
219
|
+
if (difference)
|
|
220
|
+
return Math.sign(difference);
|
|
221
|
+
}
|
|
222
|
+
// A version without a pre-release ranks above any pre-release of it.
|
|
223
|
+
if (!left.prerelease.length || !right.prerelease.length)
|
|
224
|
+
return Math.sign(right.prerelease.length - left.prerelease.length);
|
|
225
|
+
const count = Math.min(left.prerelease.length, right.prerelease.length);
|
|
226
|
+
for (let at = 0; at < count; at++) {
|
|
227
|
+
const x = left.prerelease[at];
|
|
228
|
+
const y = right.prerelease[at];
|
|
229
|
+
if (x === y)
|
|
230
|
+
continue;
|
|
231
|
+
const xNumeric = /^\d+$/.test(x);
|
|
232
|
+
const yNumeric = /^\d+$/.test(y);
|
|
233
|
+
if (xNumeric && yNumeric)
|
|
234
|
+
return Math.sign(Number(x) - Number(y));
|
|
235
|
+
if (xNumeric !== yNumeric)
|
|
236
|
+
return xNumeric ? -1 : 1;
|
|
237
|
+
return x < y ? -1 : 1;
|
|
238
|
+
}
|
|
239
|
+
return Math.sign(left.prerelease.length - right.prerelease.length);
|
|
240
|
+
}
|
package/dist/node/cli.js
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
+
import { CatalogError } from "./catalog.js";
|
|
5
6
|
import { checkPackage, PackageError, readPackageDirectory } from "./package.js";
|
|
7
|
+
import { PublishError, writeCatalog, writeCatalogKeys } from "./publish.js";
|
|
6
8
|
import { writeZip, ZipError } from "./zip.js";
|
|
7
|
-
const usage =
|
|
9
|
+
const usage = [
|
|
10
|
+
'Usage:',
|
|
11
|
+
' vormio-plugin pack <plugin directory> <output.vormioplugin>',
|
|
12
|
+
' vormio-plugin keygen <output path prefix>',
|
|
13
|
+
' vormio-plugin catalog <release config.json> --key <private key.pem> --out <new folder>',
|
|
14
|
+
].join('\n');
|
|
8
15
|
/** `vormio-plugin pack`: validates a built plugin directory and writes a `.vormioplugin` file. */
|
|
9
|
-
async function
|
|
10
|
-
const [command, source, output] = args;
|
|
11
|
-
if (command !== 'pack' || !source || !output) {
|
|
12
|
-
console.error(usage);
|
|
13
|
-
return 2;
|
|
14
|
-
}
|
|
16
|
+
async function pack(source, output) {
|
|
15
17
|
if (!output.endsWith('.vormioplugin')) {
|
|
16
18
|
console.error('The output file must end with .vormioplugin');
|
|
17
19
|
return 2;
|
|
@@ -21,23 +23,76 @@ async function main(args) {
|
|
|
21
23
|
console.error(`${target} already exists; choose another name or remove it first`);
|
|
22
24
|
return 1;
|
|
23
25
|
}
|
|
26
|
+
const contents = await readPackageDirectory(resolve(source));
|
|
27
|
+
const checked = checkPackage(contents);
|
|
28
|
+
const entries = checked.files.map((file) => ({
|
|
29
|
+
path: file.path,
|
|
30
|
+
data: contents.get(file.path),
|
|
31
|
+
}));
|
|
32
|
+
await writeFile(target, writeZip(entries), { flag: 'wx' });
|
|
33
|
+
console.log(`${checked.manifest.id} ${checked.manifest.version}: ${entries.length} files → ${target}`);
|
|
34
|
+
console.log(`digest ${checked.digest}`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/** `vormio-plugin keygen`: creates the key pair a catalog is signed with. */
|
|
38
|
+
async function keygen(prefix) {
|
|
39
|
+
const keys = await writeCatalogKeys(prefix);
|
|
40
|
+
console.log(`key ${keys.keyId}`);
|
|
41
|
+
console.log(`public key ${keys.publicKey}`);
|
|
42
|
+
console.log(`private key → ${keys.privateKeyFile}`);
|
|
43
|
+
console.log(`public key → ${keys.publicKeyFile}`);
|
|
44
|
+
console.log('Keep the private key offline. Never commit, upload or share it.');
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/** `vormio-plugin catalog`: publishes a signed catalog and its packages into a new folder. */
|
|
48
|
+
async function catalog(args) {
|
|
49
|
+
let config;
|
|
50
|
+
let key;
|
|
51
|
+
let out;
|
|
52
|
+
for (let at = 0; at < args.length; at++) {
|
|
53
|
+
const arg = args[at];
|
|
54
|
+
if (arg === '--key')
|
|
55
|
+
key = args[++at];
|
|
56
|
+
else if (arg === '--out')
|
|
57
|
+
out = args[++at];
|
|
58
|
+
else if (config === undefined && arg && !arg.startsWith('--'))
|
|
59
|
+
config = arg;
|
|
60
|
+
else
|
|
61
|
+
return usageError();
|
|
62
|
+
}
|
|
63
|
+
if (!config || !key || !out)
|
|
64
|
+
return usageError();
|
|
65
|
+
const written = await writeCatalog({ config, privateKey: key, out });
|
|
66
|
+
console.log(`catalog.json: sequence ${written.sequence}, key ${written.keyId}`);
|
|
67
|
+
for (const release of written.releases)
|
|
68
|
+
console.log(` ${release.id} ${release.version} → ${release.file}`);
|
|
69
|
+
console.log(`Upload the packages folder of ${resolve(out)} first, then catalog.json.`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
function usageError() {
|
|
73
|
+
console.error(usage);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
async function main(args) {
|
|
77
|
+
const [command, ...rest] = args;
|
|
24
78
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
console.log(`${checked.manifest.id} ${checked.manifest.version}: ${entries.length} files → ${target}`);
|
|
33
|
-
console.log(`digest ${checked.digest}`);
|
|
34
|
-
return 0;
|
|
79
|
+
if (command === 'pack' && rest.length === 2)
|
|
80
|
+
return await pack(rest[0], rest[1]);
|
|
81
|
+
if (command === 'keygen' && rest.length === 1)
|
|
82
|
+
return await keygen(rest[0]);
|
|
83
|
+
if (command === 'catalog')
|
|
84
|
+
return await catalog(rest);
|
|
85
|
+
return usageError();
|
|
35
86
|
}
|
|
36
87
|
catch (error) {
|
|
37
88
|
if (error instanceof PackageError || error instanceof ZipError) {
|
|
38
89
|
console.error(`The plugin is not valid:\n${error.message}`);
|
|
39
90
|
return 1;
|
|
40
91
|
}
|
|
92
|
+
if (error instanceof PublishError || error instanceof CatalogError) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
41
96
|
throw error;
|
|
42
97
|
}
|
|
43
98
|
}
|
package/dist/node/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, type CheckedPackage, } from './package.ts';
|
|
2
2
|
export { readZip, writeZip, ZipError, type ZipEntry } from './zip.ts';
|
|
3
|
+
export { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, releaseManifest, signCatalog, verifyCatalog, type Catalog, type CatalogErrorCode, type CatalogKeys, type CatalogPayload, type CatalogPlugin, type CatalogRelease, } from './catalog.ts';
|
|
4
|
+
export { PublishError, writeCatalog, writeCatalogKeys, type WrittenCatalog, type WrittenKeys, } from './publish.ts';
|
|
3
5
|
export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, type Contribution, type ContributionKind, type PluginManifest, } from '../manifest.ts';
|
|
4
6
|
export { checkPackageFiles, checkPackagePath, packageLimits } from '../package-rules.ts';
|
|
5
7
|
export { compositionReportSchema, parseReport, reportLimits } from '../report.ts';
|
package/dist/node/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, } from "./package.js";
|
|
2
2
|
export { readZip, writeZip, ZipError } from "./zip.js";
|
|
3
|
+
export { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, releaseManifest, signCatalog, verifyCatalog, } from "./catalog.js";
|
|
4
|
+
export { PublishError, writeCatalog, writeCatalogKeys, } from "./publish.js";
|
|
3
5
|
// The manifest and package rules again, so Node code needs no DOM types from the runtime entry.
|
|
4
6
|
export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, } from "../manifest.js";
|
|
5
7
|
export { checkPackageFiles, checkPackagePath, packageLimits } from "../package-rules.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A publication that cannot go ahead; nothing has been written. */
|
|
2
|
+
export declare class PublishError extends Error {
|
|
3
|
+
readonly problems: readonly string[];
|
|
4
|
+
constructor(problems: readonly string[]);
|
|
5
|
+
}
|
|
6
|
+
export interface WrittenKeys {
|
|
7
|
+
keyId: string;
|
|
8
|
+
publicKey: string;
|
|
9
|
+
privateKeyFile: string;
|
|
10
|
+
publicKeyFile: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generates a catalog key pair into `<prefix>.private.pem` and `<prefix>.public.txt`. Refuses to
|
|
14
|
+
* write inside a Git working tree, where the private key could end up in a commit, and never
|
|
15
|
+
* overwrites a file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function writeCatalogKeys(prefix: string): Promise<WrittenKeys>;
|
|
18
|
+
export interface WrittenCatalog {
|
|
19
|
+
keyId: string;
|
|
20
|
+
sequence: number;
|
|
21
|
+
releases: {
|
|
22
|
+
id: string;
|
|
23
|
+
version: string;
|
|
24
|
+
file: string;
|
|
25
|
+
bytes: number;
|
|
26
|
+
sha256: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Publishes a catalog into a new, empty folder: validates every package the way a host does before
|
|
31
|
+
* installing it, copies each under a name derived from its content, signs `catalog.json` and checks
|
|
32
|
+
* the result with the public key. Every check runs before anything is written. Upload the
|
|
33
|
+
* `packages` folder first and `catalog.json` last.
|
|
34
|
+
*/
|
|
35
|
+
export declare function writeCatalog(options: {
|
|
36
|
+
/** Release configuration; relative paths in it are resolved against its folder. */
|
|
37
|
+
config: string;
|
|
38
|
+
/** PKCS#8 PEM file of the catalog's private key. */
|
|
39
|
+
privateKey: string;
|
|
40
|
+
out: string;
|
|
41
|
+
now?: Date;
|
|
42
|
+
}): Promise<WrittenCatalog>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { packageLimits } from "../package-rules.js";
|
|
7
|
+
import { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, signCatalog, verifyCatalog, } from "./catalog.js";
|
|
8
|
+
import { PackageError, readPackageArchive } from "./package.js";
|
|
9
|
+
import { ZipError } from "./zip.js";
|
|
10
|
+
/** A publication that cannot go ahead; nothing has been written. */
|
|
11
|
+
export class PublishError extends Error {
|
|
12
|
+
problems;
|
|
13
|
+
constructor(problems) {
|
|
14
|
+
super(problems.join('\n'));
|
|
15
|
+
this.problems = problems;
|
|
16
|
+
this.name = 'PublishError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The Git working tree containing a folder, if any. */
|
|
20
|
+
function gitWorkingTree(folder) {
|
|
21
|
+
for (let current = resolve(folder);;) {
|
|
22
|
+
if (existsSync(join(current, '.git')))
|
|
23
|
+
return current;
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current)
|
|
26
|
+
return undefined;
|
|
27
|
+
current = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readInput(path, what) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(path);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT')
|
|
36
|
+
throw new PublishError([`${what} not found: ${path}`]);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a catalog key pair into `<prefix>.private.pem` and `<prefix>.public.txt`. Refuses to
|
|
42
|
+
* write inside a Git working tree, where the private key could end up in a commit, and never
|
|
43
|
+
* overwrites a file.
|
|
44
|
+
*/
|
|
45
|
+
export async function writeCatalogKeys(prefix) {
|
|
46
|
+
const base = resolve(prefix);
|
|
47
|
+
const repository = gitWorkingTree(dirname(base));
|
|
48
|
+
if (repository)
|
|
49
|
+
throw new PublishError([
|
|
50
|
+
`${dirname(base)} is inside the Git working tree ${repository}; keep private keys outside every repository`,
|
|
51
|
+
]);
|
|
52
|
+
const privateKeyFile = `${base}.private.pem`;
|
|
53
|
+
const publicKeyFile = `${base}.public.txt`;
|
|
54
|
+
for (const file of [privateKeyFile, publicKeyFile])
|
|
55
|
+
if (existsSync(file))
|
|
56
|
+
throw new PublishError([`${file} already exists`]);
|
|
57
|
+
const keys = generateCatalogKeys();
|
|
58
|
+
await mkdir(dirname(base), { recursive: true });
|
|
59
|
+
await writeFile(privateKeyFile, keys.privateKey, { flag: 'wx', mode: 0o600 });
|
|
60
|
+
await writeFile(publicKeyFile, `${keys.publicKey}\n`, { flag: 'wx' });
|
|
61
|
+
return { keyId: keys.keyId, publicKey: keys.publicKey, privateKeyFile, publicKeyFile };
|
|
62
|
+
}
|
|
63
|
+
const releaseConfigSchema = z
|
|
64
|
+
.strictObject({
|
|
65
|
+
/** The catalog currently published; the new one continues its sequence. */
|
|
66
|
+
previous: z.string().min(1).optional(),
|
|
67
|
+
/** The sequence of a first publication, when there is no previous catalog. */
|
|
68
|
+
initialSequence: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
69
|
+
/** Every package the new catalog offers, as `.vormioplugin` files. */
|
|
70
|
+
packages: z
|
|
71
|
+
.array(z.string().min(1))
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(catalogLimits.maxPlugins * catalogLimits.maxReleasesPerPlugin),
|
|
74
|
+
})
|
|
75
|
+
.refine((config) => (config.previous === undefined) !== (config.initialSequence === undefined), {
|
|
76
|
+
message: 'Give either previous or initialSequence',
|
|
77
|
+
});
|
|
78
|
+
async function readRelease(path) {
|
|
79
|
+
const size = (await stat(path).catch(() => undefined))?.size;
|
|
80
|
+
if (size === undefined)
|
|
81
|
+
throw new PublishError([`Package not found: ${path}`]);
|
|
82
|
+
if (size > packageLimits.maxArchiveBytes)
|
|
83
|
+
throw new PublishError([`${path}: larger than ${packageLimits.maxArchiveBytes} bytes`]);
|
|
84
|
+
const data = await readInput(path, 'Package');
|
|
85
|
+
try {
|
|
86
|
+
const { checked, contents } = readPackageArchive(data);
|
|
87
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
88
|
+
const { id, version } = checked.manifest;
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
version,
|
|
92
|
+
// The manifest as the package has it, so hosts compare like with like after downloading.
|
|
93
|
+
manifest: JSON.parse(contents.get('manifest.json').toString('utf8')),
|
|
94
|
+
data,
|
|
95
|
+
sha256,
|
|
96
|
+
file: `packages/${id}-${version}-${sha256.slice(0, 12)}.vormioplugin`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof PackageError)
|
|
101
|
+
throw new PublishError(error.problems.map((problem) => `${path}: ${problem}`));
|
|
102
|
+
if (error instanceof ZipError)
|
|
103
|
+
throw new PublishError([`${path}: ${error.message}`]);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Publishes a catalog into a new, empty folder: validates every package the way a host does before
|
|
109
|
+
* installing it, copies each under a name derived from its content, signs `catalog.json` and checks
|
|
110
|
+
* the result with the public key. Every check runs before anything is written. Upload the
|
|
111
|
+
* `packages` folder first and `catalog.json` last.
|
|
112
|
+
*/
|
|
113
|
+
export async function writeCatalog(options) {
|
|
114
|
+
const configFile = resolve(options.config);
|
|
115
|
+
const base = dirname(configFile);
|
|
116
|
+
let json;
|
|
117
|
+
try {
|
|
118
|
+
json = JSON.parse((await readInput(configFile, 'Release configuration')).toString('utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof SyntaxError)
|
|
122
|
+
throw new PublishError([`${configFile}: not valid JSON`]);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const parsed = releaseConfigSchema.safeParse(json);
|
|
126
|
+
if (!parsed.success)
|
|
127
|
+
throw new PublishError(parsed.error.issues.map((issue) => `${issue.path.join('.') || 'config'}: ${issue.message}`));
|
|
128
|
+
const config = parsed.data;
|
|
129
|
+
const privateKey = (await readInput(resolve(options.privateKey), 'Private key')).toString('utf8');
|
|
130
|
+
const publicKey = catalogPublicKey(privateKey);
|
|
131
|
+
const keyId = catalogKeyId(publicKey);
|
|
132
|
+
// The private key cannot be inside an empty folder, so this also keeps it out of the upload.
|
|
133
|
+
const out = resolve(options.out);
|
|
134
|
+
if (existsSync(out) && (await readdir(out)).length > 0)
|
|
135
|
+
throw new PublishError([`${out} is not empty; publish into a new folder`]);
|
|
136
|
+
let sequence = config.initialSequence;
|
|
137
|
+
if (config.previous !== undefined) {
|
|
138
|
+
const previousFile = resolve(base, config.previous);
|
|
139
|
+
try {
|
|
140
|
+
sequence =
|
|
141
|
+
verifyCatalog(await readInput(previousFile, 'Previous catalog'), publicKey).sequence + 1;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof CatalogError)
|
|
145
|
+
throw new PublishError(error.problems.map((problem) => `${previousFile}: ${problem}`));
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const problems = [];
|
|
150
|
+
const plugins = new Map();
|
|
151
|
+
for (const path of config.packages) {
|
|
152
|
+
try {
|
|
153
|
+
const release = await readRelease(resolve(base, path));
|
|
154
|
+
const releases = plugins.get(release.id) ?? [];
|
|
155
|
+
if (releases.some((other) => other.version === release.version))
|
|
156
|
+
problems.push(`${release.id} ${release.version} is listed more than once`);
|
|
157
|
+
releases.push(release);
|
|
158
|
+
plugins.set(release.id, releases);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (!(error instanceof PublishError))
|
|
162
|
+
throw error;
|
|
163
|
+
problems.push(...error.problems);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (problems.length)
|
|
167
|
+
throw new PublishError(problems);
|
|
168
|
+
const ordered = [...plugins.entries()]
|
|
169
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
170
|
+
.map(([id, releases]) => ({
|
|
171
|
+
id,
|
|
172
|
+
releases: releases.sort((a, b) => compareVersions(b.version, a.version)),
|
|
173
|
+
}));
|
|
174
|
+
const payload = {
|
|
175
|
+
sequence,
|
|
176
|
+
publishedAt: (options.now ?? new Date()).toISOString(),
|
|
177
|
+
plugins: ordered.map(({ id, releases }) => ({
|
|
178
|
+
id,
|
|
179
|
+
releases: releases.map(({ manifest, file, data, sha256 }) => ({
|
|
180
|
+
manifest: manifest,
|
|
181
|
+
file,
|
|
182
|
+
bytes: data.length,
|
|
183
|
+
sha256,
|
|
184
|
+
})),
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
let catalog;
|
|
188
|
+
try {
|
|
189
|
+
catalog = signCatalog(payload, privateKey);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error instanceof CatalogError)
|
|
193
|
+
throw new PublishError([...error.problems]);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
await mkdir(join(out, 'packages'), { recursive: true });
|
|
197
|
+
const releases = ordered.flatMap(({ releases }) => releases);
|
|
198
|
+
for (const release of releases)
|
|
199
|
+
await writeFile(join(out, release.file), release.data, { flag: 'wx' });
|
|
200
|
+
await writeFile(join(out, 'catalog.json'), catalog, { flag: 'wx' });
|
|
201
|
+
verifyCatalog(await readFile(join(out, 'catalog.json')), publicKey);
|
|
202
|
+
return {
|
|
203
|
+
keyId,
|
|
204
|
+
sequence,
|
|
205
|
+
releases: releases.map(({ id, version, file, data, sha256 }) => ({
|
|
206
|
+
id,
|
|
207
|
+
version,
|
|
208
|
+
file,
|
|
209
|
+
bytes: data.length,
|
|
210
|
+
sha256,
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
package/dist/package-rules.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare const packageLimits: {
|
|
|
4
4
|
readonly maxFileBytes: number;
|
|
5
5
|
readonly maxTotalBytes: number;
|
|
6
6
|
readonly maxPathLength: 240;
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
readonly maxArchiveBytes: number;
|
|
7
9
|
};
|
|
8
10
|
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
9
11
|
export declare const packageExtensions: ReadonlySet<string>;
|
package/dist/package-rules.js
CHANGED
|
@@ -4,6 +4,8 @@ export const packageLimits = {
|
|
|
4
4
|
maxFileBytes: 16 * 1024 * 1024,
|
|
5
5
|
maxTotalBytes: 64 * 1024 * 1024,
|
|
6
6
|
maxPathLength: 240,
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
maxArchiveBytes: 96 * 1024 * 1024,
|
|
7
9
|
};
|
|
8
10
|
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
9
11
|
export const packageExtensions = new Set([
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export type { CompanionResult, CompanionStatus, CompositionReport, PluginBridge, PluginInput };
|
|
1
|
+
import { isNotApplicable, type CompanionResult, type CompanionStatus, type CompositionReport, type NotApplicable, type PluginBridge, type PluginInput } from './bridge.ts';
|
|
2
|
+
export type { CompanionResult, CompanionStatus, CompositionReport, NotApplicable, PluginBridge, PluginInput, };
|
|
3
|
+
export { isNotApplicable };
|
|
4
|
+
/**
|
|
5
|
+
* The answer for a file that is not this handler's format. Return it from a viewer, thumbnail or
|
|
6
|
+
* analyzer handler; a file of your format with a problem should throw instead, so the user sees why.
|
|
7
|
+
*/
|
|
8
|
+
export declare function notApplicable(): NotApplicable;
|
|
3
9
|
/** The host bridge; throws outside a Vormio plugin page. */
|
|
4
10
|
export declare function bridge(): PluginBridge;
|
|
5
11
|
/**
|
|
@@ -11,16 +17,17 @@ export declare function readCompanion(input: PluginInput, relativePath: string):
|
|
|
11
17
|
export declare function readInput(input: PluginInput): Promise<ArrayBuffer>;
|
|
12
18
|
/**
|
|
13
19
|
* Runs a viewer: `show` is called for the first input and again whenever the host moves to another
|
|
14
|
-
* file, with the previous call's signal aborted. Resolving reports ready
|
|
15
|
-
* error
|
|
20
|
+
* file, with the previous call's signal aborted. Resolving reports ready, resolving with
|
|
21
|
+
* `notApplicable()` reports the file is not this viewer's format, and throwing reports the error
|
|
22
|
+
* message.
|
|
16
23
|
*/
|
|
17
|
-
export declare function viewer(show: (input: PluginInput, signal: AbortSignal) => Promise<void>): void;
|
|
18
|
-
/** Runs a thumbnail provider; a canvas is converted to PNG. */
|
|
24
|
+
export declare function viewer(show: (input: PluginInput, signal: AbortSignal) => Promise<void | NotApplicable>): void;
|
|
25
|
+
/** Runs a thumbnail provider; a canvas is converted to PNG. `render` may return `notApplicable()`. */
|
|
19
26
|
export declare function thumbnail(render: (input: PluginInput, size: {
|
|
20
27
|
maxEdge: number;
|
|
21
|
-
}) => Promise<Blob | HTMLCanvasElement | OffscreenCanvas>): void;
|
|
28
|
+
}) => Promise<Blob | HTMLCanvasElement | OffscreenCanvas | NotApplicable>): void;
|
|
22
29
|
/**
|
|
23
30
|
* Runs an analyzer: `analyze` is called once per request and returns the composition report of
|
|
24
|
-
* the input
|
|
31
|
+
* the input, or `notApplicable()`. Throwing reports the analysis as failed with the error message.
|
|
25
32
|
*/
|
|
26
|
-
export declare function analyzer(analyze: (input: PluginInput) => Promise<CompositionReport>): void;
|
|
33
|
+
export declare function analyzer(analyze: (input: PluginInput) => Promise<CompositionReport | NotApplicable>): void;
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { isNotApplicable, } from "./bridge.js";
|
|
2
|
+
export { isNotApplicable };
|
|
3
|
+
/**
|
|
4
|
+
* The answer for a file that is not this handler's format. Return it from a viewer, thumbnail or
|
|
5
|
+
* analyzer handler; a file of your format with a problem should throw instead, so the user sees why.
|
|
6
|
+
*/
|
|
7
|
+
export function notApplicable() {
|
|
8
|
+
return { notApplicable: true };
|
|
9
|
+
}
|
|
1
10
|
/** The host bridge; throws outside a Vormio plugin page. */
|
|
2
11
|
export function bridge() {
|
|
3
12
|
const host = globalThis.vormio;
|
|
@@ -38,8 +47,9 @@ export async function readInput(input) {
|
|
|
38
47
|
}
|
|
39
48
|
/**
|
|
40
49
|
* Runs a viewer: `show` is called for the first input and again whenever the host moves to another
|
|
41
|
-
* file, with the previous call's signal aborted. Resolving reports ready
|
|
42
|
-
* error
|
|
50
|
+
* file, with the previous call's signal aborted. Resolving reports ready, resolving with
|
|
51
|
+
* `notApplicable()` reports the file is not this viewer's format, and throwing reports the error
|
|
52
|
+
* message.
|
|
43
53
|
*/
|
|
44
54
|
export function viewer(show) {
|
|
45
55
|
const host = bridge();
|
|
@@ -49,8 +59,12 @@ export function viewer(show) {
|
|
|
49
59
|
const controller = new AbortController();
|
|
50
60
|
current = controller;
|
|
51
61
|
try {
|
|
52
|
-
await show(input, controller.signal);
|
|
53
|
-
if (
|
|
62
|
+
const result = await show(input, controller.signal);
|
|
63
|
+
if (controller.signal.aborted)
|
|
64
|
+
return;
|
|
65
|
+
if (isNotApplicable(result))
|
|
66
|
+
host.notApplicable(input);
|
|
67
|
+
else
|
|
54
68
|
host.ready(input);
|
|
55
69
|
}
|
|
56
70
|
catch (error) {
|
|
@@ -61,10 +75,12 @@ export function viewer(show) {
|
|
|
61
75
|
host.onInput((input) => void run(input));
|
|
62
76
|
void host.getInput().then(run);
|
|
63
77
|
}
|
|
64
|
-
/** Runs a thumbnail provider; a canvas is converted to PNG. */
|
|
78
|
+
/** Runs a thumbnail provider; a canvas is converted to PNG. `render` may return `notApplicable()`. */
|
|
65
79
|
export function thumbnail(render) {
|
|
66
80
|
bridge().onThumbnailRequest(async (input, size) => {
|
|
67
81
|
const result = await render(input, size);
|
|
82
|
+
if (isNotApplicable(result))
|
|
83
|
+
return result;
|
|
68
84
|
let blob;
|
|
69
85
|
if (result instanceof Blob)
|
|
70
86
|
blob = result;
|
|
@@ -77,7 +93,7 @@ export function thumbnail(render) {
|
|
|
77
93
|
}
|
|
78
94
|
/**
|
|
79
95
|
* Runs an analyzer: `analyze` is called once per request and returns the composition report of
|
|
80
|
-
* the input
|
|
96
|
+
* the input, or `notApplicable()`. Throwing reports the analysis as failed with the error message.
|
|
81
97
|
*/
|
|
82
98
|
export function analyzer(analyze) {
|
|
83
99
|
bridge().onAnalyzeRequest(analyze);
|