npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vormio/plugin-sdk

v0.0.12

Published

Manifest schema, runtime helpers and packaging for Vormio plugins.

Readme

@vormio/plugin-sdk

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 (currently 0.0.12) and published to npm under the MIT license: npm install @vormio/[email protected].

Plugin contract version: apiVersion: 5. During development (0.0.x) there is no cross-version compatibility promise: the host loads only plugins with the same apiVersion as its own.

What a plugin can do

A plugin provides three capabilities for the formats it declares. They are independent: declaring one does not grant the others.

| Extension point | Runs in | Result | | --------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Thumbnail | A hidden sandboxed page; Canvas and WebGL work | One PNG whose longest edge is at most the host's maxEdge (currently 320); the host then caps it at 512×512 and 4 MiB | | Viewer | A sandboxed page over Vormio's viewing area | Shows the file read-only; the user can interact with it | | Analyzer | A hidden sandboxed page | A composition report: the file's internal subresources, the files it references and diagnostics; see Analyzers |

A plugin runs only after it is enabled in a library. Each plugin has its own memory partition:

  • No access to the network, file: or other plugins.
  • No Node and no eval.
  • WebAssembly and Workers are available.
  • It can read only the file the host binds to it, plus companion files in the same linked folder whose extensions are listed in companionFormats.
  • It cannot modify any file.

Package layout

A plugin is a folder: a manifest.json at the root plus the built static files.

{
  "apiVersion": 5,
  "id": "acme.model-viewer",
  "version": "0.1.0",
  "name": "Model Viewer",
  "description": "Example.",
  "contributions": {
    "thumbnails": [
      {
        "id": "model",
        "entry": "thumbnail.html",
        "formats": ["gltf", "glb"],
        "companionFormats": ["bin", "png", "jpg"]
      }
    ],
    "viewers": [
      {
        "id": "model",
        "entry": "viewer.html",
        "formats": ["gltf", "glb"],
        "companionFormats": ["bin", "png", "jpg"]
      }
    ],
    "analyzers": [{ "id": "model", "entry": "analyzer.html", "formats": ["gltf", "glb"] }]
  }
}
  • thumbnails, viewers and analyzers are each optional, but there must be at least one extension point. Each kind allows up to 8, with unique IDs within the kind.
  • id is publisher.name, using only lowercase letters, digits and hyphens. The vormio publisher is reserved for the host.
  • formats and companionFormats are lowercase extensions without the dot. Wildcards are not allowed.
  • entry is an HTML file in the package. Pages run under a strict Content Security Policy: scripts must be separate files, not inline.
  • agreement is optional; see Agreements users accept before enabling.
  • Unknown fields are rejected.

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.

Limits: at most 1,024 files, 16 MiB per file and 64 MiB in total; the .vormioplugin file itself is at most 96 MiB.

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:

  • an upgrade that changes only code, name or version
  • an upgrade that reads less
  • a rollback

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.

Agreements users accept before enabling

Some third-party terms require every user of the plugin to accept them, such as a runtime license that each user must hold. Name the terms in the manifest:

{
  "agreement": {
    "title": "Spine Runtimes License Agreement",
    "file": "licenses/SPINE-RUNTIMES.txt"
  }
}
  • title is 1–80 characters without control characters. file is a .txt or .md file in the package, at most 256 KiB of UTF-8; Markdown is shown as plain text. A package whose agreement file is missing, too large or not UTF-8 is invalid. One agreement per plugin.
  • When the plugin is enabled in a library, the host's confirmation names the agreement and offers its full text; the plugin is enabled only when the user agrees. Declining leaves the plugin installed and not enabled. Development folders ask the same way.
  • The library records the SHA-256 of the file's bytes with the grant. A version whose agreement file differs needs enabling again, like reading more formats; a version with a file the user already accepted, such as one rolled back to, does not.
  • Use it only for terms that require acceptance by each user. The host shows and records the user's choice; neither the host nor your plugin can verify that the user meets the terms, so do not suggest otherwise.

Runtime API

import { analyzer, readCompanion, readInput, thumbnail, viewer } from '@vormio/plugin-sdk'

// Viewer: called first and on every file change. Returning means ready; throwing reports a failure;
// returning notApplicable() hands the file to the next viewer (see below).
viewer(async (input, signal) => {
  const bytes = await readInput(input)
  const texture = await readCompanion(input, 'textures/base.png')
  if (texture.status !== 'ok') console.warn(texture.status)
  // …render; signal aborts when the viewer moves to the next file.
})

// Thumbnail: return a Blob, HTMLCanvasElement or OffscreenCanvas.
thumbnail(async (input, { maxEdge }) => {
  const canvas = new OffscreenCanvas(maxEdge, maxEdge)
  // …draw
  return canvas
})

// Analyzer: return the file's composition report; see the next section.
analyzer(async (input) => {
  const gltf = JSON.parse(new TextDecoder().decode(await readInput(input)))
  // …
  return { subresources: [], references: [], diagnostics: [] }
})
  • input.url is the main file's address. Companion files resolve by relative address, just as on disk: for textures/a.png in a glTF, fetch(new URL('textures/a.png', input.url)) works, and loaders can use these addresses directly. The addresses support Range requests.
  • Read results:

| Status | Meaning | HTTP | | --------------- | ----------------------------------------------------------------------------------------------------------- | ----- | | ok | Read succeeded | 200 | | out-of-bounds | Invalid path | 400 | | unauthorized | The extension is not in companionFormats | 403 | | over-limit | More than 256 companion requests in one binding | 429 | | missing | The file is not in the linked folder, or it was skipped by the scan, removed from the library or is missing | 404 | | changed | The file changed on disk and no longer matches the library's record | 409 | | unavailable | Any other reason, such as an offline linked folder or an expired binding | Other |

  • 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).
  • 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.
  • viewer(show, { focus }) also receives the host's requests to show a subresource of the shown file; see Focusing a subresource.

Files that are not your format

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:

import { analyzer, notApplicable, readInput, thumbnail, viewer } from '@vormio/plugin-sdk'

analyzer(async (input) => {
  const data = JSON.parse(new TextDecoder().decode(await readInput(input)))
  if (!isSkeleton(data)) return notApplicable()
  return analyzeSkeleton(data)
})

thumbnail() and viewer() accept the same answer.

  • It is not a failure. The host records no reason and shows nothing for it:
    • an analyzer's file gets no composition section;
    • thumbnails and viewers move on to the next enabled plugin for the format, then to the built-in preview or the type icon.
  • 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.
  • 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.
  • A file that is your format but has a problem should throw, or report diagnostics, so the user sees why.
  • 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.

Analyzers

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).

A report describes one version of the analyzed file:

interface CompositionReport {
  /** Locatable objects inside the file, at most 10,000. */
  subresources: {
    key: string // Unique within this file version, chosen by the plugin, e.g. "materials/0"
    kind: string // Display category, e.g. "Material"; the host uses it only for grouping
    name: string // Display name; may be an empty string
    parent?: string // Key of the parent subresource, for display hierarchy
  }[]
  /** References, at most 50,000. */
  references: {
    from?: string // Key of the referencing subresource; omitted means the file itself
    uri?: string // Points to another file: the raw address written in the file
    uris?: string[] // Several candidate files, the preferred first; only with uriKey
    key?: string // Points to a subresource in this file; give exactly one of uri, uris and key
    uriKey?: string // With uri or uris: a subresource of that file, by the key its report gives it
    role: string // Display role, e.g. "Base color texture"
    evidence?: string // Location in the file, e.g. "/images/2/uri"
    inferred?: string // Not written in the file: how you inferred it, e.g. "Same name as the skeleton"
  }[]
  /** Problems found during analysis, at most 500. */
  diagnostics: { severity: 'error' | 'warning' | 'info'; message: string; key?: string }[]
  /** Only part of the analysis completed. */
  partial?: boolean
}
  • The host validates reports with compositionReportSchema (parseReport also checks the size); plugins can check themselves against the same schema before returning. The report serialized as JSON is at most 4 MiB (counted in UTF-8 bytes). Names and keys are at most 200 characters, kinds and roles 80, bases of inference 120, evidence 300, diagnostics 500 and addresses 2,048; uris has 2–16 addresses. Names, kinds, roles, bases of inference and evidence must not contain control characters.
  • The report is invalid if it has duplicate keys, a parent, from or key that points to a missing subresource, a cyclic hierarchy, a reference with other than exactly one of uri, uris and key, a uriKey with a key, uris without a uriKey or with a repeated address, or an inferred reference with a key. A uriKey is not checked against this report: it names a subresource of the other file.
  • Write uri exactly as it appears in the file; do not resolve it yourself. The host decodes percent-encoding once, ignores everything after ? and #, treats \ as /, resolves it relative to the analyzed file and classifies it: a file in the linked folder, out of bounds (.. leaving the linked folder), embedded (data:), external (absolute paths, drive letters, other schemes) or invalid (undecodable, containing control characters, or pointing to a folder). Whether the target exists is decided by the host at query time.
  • For embedded data (data:), report only the header up to the comma, e.g. data:image/png;base64. The host classifies it as embedded by prefix and does not store the content; a full data: address often exceeds 2,048 characters and would invalidate the whole report.
  • Reporting a reference does not require reading the target file, so you do not need companionFormats just to report references. Declare them only when the analysis actually reads companion files.

Inferred references

Some formats pair files by convention rather than by an address in the file. A Spine skeleton does not name its atlas; the runtime pairs hero.json with hero.atlas because they share a name. Report such a relation as an inferred reference, with the basis of the inference in inferred:

analyzer(async (input) => {
  const skeleton = parseSkeleton(await readInput(input))
  const atlas = `${input.name.replace(/\.[^.]*$/, '')}.atlas`
  return {
    ...skeleton.report,
    references: [
      ...skeleton.report.references,
      { uri: atlas, role: 'Atlas', inferred: 'Same name as the skeleton' },
    ],
  }
})
  • Mark a reference as inferred only when the file does not write the address. References read from the file stay as they are.
  • The basis is shown to the user as is. Say why you think the relation exists, not where it is.
  • Write the address relative to the analyzed file and do not check that the target exists: the host decides at query time, so a target that appears or disappears needs no new analysis.
  • The host shows inferred references apart from written ones. A missing target is shown as "not found": it does not count as a missing reference, does not appear in the missing references filter and gets no note about where a moved file went, because the convention may simply not apply.
  • The inference may depend on the file's name. When an analyzed file with inferred references is renamed or moved, the host analyzes it again.
  • Do not report the same target both as written and as inferred.

References to subresources of other files

When a format looks an object up by name in another file, report the object itself: give the file's address in uri and the object's key in that file's report in uriKey. A Spine skeleton's attachment uses the region of the same name in the paired atlas:

{
  from: 'attachments/default/head/head',
  uri: 'hero.atlas',
  uriKey: 'region:head-front', // The key your atlas analyzer gives that region
  role: 'Region',
  inferred: 'Same name as the skeleton', // The atlas itself is inferred
}
  • A key that other files refer to is a contract within your plugin: derive it from the format's own lookup rule (for Spine, the region name), so that it stays the same when the target file is exported again. Never use a position in a list.
  • The host connects the two reports only when the same build of your plugin analyzed both files (same plugin ID and package). Your analyzers for both formats must be in one plugin.
  • A build is identified by the package digest: a SHA-256 over every file's path and content, the digest that vormio-plugin pack prints. A development folder gets the digest of its files, the same as the package made from them, so any change to any file there, a viewer or thumbnail file included, is a new build. After a new build the host analyzes all your files again; while one end of a reference has been analyzed by the new build and the other not yet, the reference is unknown, with that reason, and it resolves again once both are done.
  • Do not read the target file to check that the subresource exists. The host decides at query time, from the target's current report, and the status follows when the target is analyzed again:

| The target file | Written reference | Inferred reference | | --------------------------------------------------------------------------------- | --------------------------- | ------------------------ | | Does not exist | Missing | Not found | | Its report has the key | Resolved | Resolved | | Its complete report lacks the key | Missing | Not found | | Its partial report lacks the key | Unknown (it may be omitted) | Unknown | | Has no report, an outdated one, a failed one, or one from another plugin or build | Unknown, with the reason | Unknown, with the reason |

  • Keys follow the limits of key: at most 200 characters. Do not put the key in the address: the host ignores everything after ? and #.

References that look in one of several files

Some formats look an object up by name in several files in turn. An OBJ file can name several material libraries, and usemtl Body uses the Body of the last library that defines it. Report one reference with every candidate file in uris, the one that wins first:

// mtllib base.mtl
// mtllib ../materials/paint.mtl
// usemtl Body
{
  from: 'groups/0',
  uris: ['../materials/paint.mtl', 'base.mtl'], // The last mtllib wins, so it comes first
  uriKey: 'material:Body',
  role: 'Material',
}
  • Use uris only when there is more than one candidate; with one, give uri as before. The order is yours to get right: the host cannot tell which file the format would use.
  • The host goes through the candidates in order, at query time, and resolves to the first whose report has the key. A candidate that does not exist is skipped. A candidate the host cannot settle stops it, and the reference is unknown with that candidate and the reason: never resolved to a later candidate while an earlier one might have the key.

| The candidates | Written reference | Inferred reference | | -------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------ | | One has the key, and every earlier one does not exist or has a complete report without it | Resolved to that one | Resolved to that one | | None exists, or every existing one has a complete report without the key | Missing | Not found | | An earlier one is ignored, offline, outside the linked folder, or has no complete report from your build | Unknown, with the reason | Unknown, with the reason |

  • A missing reference counts once, however many candidates it has. The reference is listed as a referrer only of the file it resolves to; a candidate whose object is shadowed by an earlier one does not list it. Report the files themselves (each mtllib) as references of their own.
  • As with uri and uriKey, every candidate must be analyzed by the same build of your plugin, and you do not read the candidates to check them.

Focusing a subresource

When the user picks a subresource in the inspector, or jumps to a subresource of another file, the host asks the viewer to show it:

viewer(showAtlas, {
  focus: (key, input) => highlightRegion(key),
})
  • The key is from your own analyzer's report: the host asks only when the viewer and the shown file's analysis come from the same build of your plugin.
  • focus is called only after the viewer reported the file ready. A request about a file the viewer has moved away from is dropped. The same key can come again when the user asks again; show it again.
  • Every input the viewer is given has a new url, also when it is a new version of the same file, and the old address stops working. viewer() drops a request whose url is not the input currently shown, so a request about an older version never lands on new content.
  • Find it if you can, for example by selecting, framing or highlighting it; ignore keys you cannot find. The host does not wait for an answer.
  • Pages that do not use viewer() subscribe with window.vormio.onFocus(({ url, key }) => …) and compare url with the input they show.

Packaging and development

The package declares "sideEffects": false: when a plugin page uses only the runtime helpers, bundlers (esbuild, Vite, Rollup, webpack) drop the manifest schema and its zod dependency, leaving about 1 KB of runtime code for a minimal page. Node-side packaging and validation are imported from @vormio/plugin-sdk/node.

npx vormio-plugin pack <build-output-folder> <output-file.vormioplugin>

The command validates the manifest and every file, then writes a zip-format .vormioplugin. It never overwrites an existing output file.

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.

When the host lacks something your plugin needs, or behaves wrongly, open an issue at Vormio/Vormio with the plugin request template: the scenario, the plugin capability it blocks, what you expect, and how to tell it works. Host capabilities are added in a generic form, never for one format. A contract change may first ship as a prerelease under the npm tag next (npm install @vormio/plugin-sdk@next) so you can try it; latest follows Vormio releases.

Plugin catalogs

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:

npx vormio-plugin keygen <key-path-prefix>
npx vormio-plugin catalog <release-config.json> --key <private-key.pem> --out <new-folder>
  • 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.

  • The release configuration lists every package in the new catalog, relative to the configuration file:

    {
      "previous": "published/3/catalog.json",
      "packages": ["artifacts/model-viewer-0.2.0.vormioplugin"]
    }

    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.

  • catalog does the following:

    • Validates every package with the rules a host applies when installing it.
    • Copies each package to packages/<plugin ID>-<version>-<first 12 hex digits of its SHA-256>.vormioplugin.
    • Writes and signs catalog.json, then verifies it with the public key.

    The output folder must be new or empty. If any check fails, nothing is written.

  • 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.

Format: catalog.json is an envelope { format: 1, keyId, payload, signature }:

  • payload is the base64 of the content's UTF-8 JSON.
  • signature is the Ed25519 signature of those bytes.
  • keyId is the first 16 hex digits of the public key's SHA-256.

The content:

interface CatalogPayload {
  sequence: number // Grows by 1 per publication; hosts refuse a sequence lower than one they have seen
  publishedAt: string // ISO 8601, UTC
  plugins: {
    id: string
    releases: {
      manifest: object // The package's manifest.json, as is
      file: string // packages/<name>.vormioplugin, relative to catalog.json
      bytes: number
      sha256: string // SHA-256 of the whole package file, lowercase hex
    }[]
  }[]
}
  • Limits:

    • catalog.json is at most 1 MiB.
    • At most 256 plugins, with at most 8 releases each.
    • Package files are at most 96 MiB.

    Plugin IDs, and the versions of one plugin, must not repeat. manifest.id must match the plugin ID.

  • One plugin can list releases for hosts of different apiVersions 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.

  • 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.

@vormio/plugin-sdk/node exports the same functionality: verifyCatalog, signCatalog, writeCatalog, writeCatalogKeys, releaseManifest and compareVersions.