Bundling and distribution
A Meridian plugin is authored as a set of TypeScript modules and a YAML manifest,
but that is not what an operator runs. What ships is a closed artifact: a folder
of self-contained JavaScript plus the manifest, produced by meridian-plugin build
and consumed by a host that never compiles or installs anything on the plugin's
behalf. This page explains why the artifact is shaped the way it is, and how it
travels from a developer's machine to a running instance.
For the mechanics — the exact commands and flags — see the how-to Build and bundle and the CLI reference. For why a plugin can be built alone in the first place, see Autonomy and packaging.
The constraint that shapes everything
A host must be able to load a plugin it has never seen, possibly from a vendor it has
no build relationship with, possibly on an air-gapped hospital network. That rules out
the usual JavaScript delivery story, where an artifact is a thin layer of code plus a
package.json whose dependencies get resolved by an npm install at the destination.
There is no destination install step here, and there must not be one: the operator
declares a plugin and boots, and the plugin runs.
So the artifact has to carry everything it needs to execute, and rely on the host for
exactly one thing — the contract every plugin already compiles against. That single
allowance is the SDK, @meridian/shared (see the SDK surface).
Everything else the plugin's runtime code touches must be inside the folder.
The closed artifact
meridian-plugin build (libs/plugin-cli/src/commands/build.ts)
collects every module the manifest references — node behaviors, port adapters, event
sources — and hands them to esbuild as entry points, bundling with the runtime
dependencies inlined. The only things left external are declared explicitly:
external: ["node:*", "@meridian/*"]
node:* is the Node runtime the host already provides; @meridian/* is the SDK the
host guarantees by contract. Nothing else escapes the bundle. A plugin that pulls in,
say, a scoring library or a date helper gets that code copied into its own dist/,
so two plugins depending on different versions of the same library never collide, and
the host resolves no cross-plugin dist and runs no install. Each plugin builds and
ships independently.
There is a subtlety worth naming, because it is easy to assume the SDK gets bundled
too. The SDK contracts a plugin imports are overwhelmingly type-only (import type),
which TypeScript erases at compile time — so most SDK references never reach runtime at
all. The @meridian/* external entry covers the residue: anything the SDK genuinely
exposes as runtime code stays a reference to the host's copy, never a second copy baked
into the plugin. One SDK, provided once by the host, shared by every plugin.
Alongside the bundled JavaScript, the build lays down three more things in dist/:
- The manifest,
plugin.yaml, copied next to the code with everymodule:path rewritten from its.tssource to the emitted.js. The YAML stays the source of truth for what the plugin contributes; the build only re-points its module paths. - Type declarations (
.d.ts), emitted best-effort viatsc --emitDeclarationOnlywhen a tsconfig is present. These matter only for other plugins thatdependsOnthis one and need its./typesexport at their build time; a leaf plugin without a tsconfig simply emits none. - i18n bundles, the sidecar
i18n/*.yamlfiles, copied beside the manifest so a translated plugin ships its locales with it (see Translate a plugin).
The result is that dist/ is exactly what you publish or hand over — no further
resolution required at the other end.
Why esbuild, and not a tsc-only build
An earlier stance compiled plugins with tsc alone: fast, faithful to the source
layout, and fine inside the repository, where the pnpm workspace resolves every
dependency by symlink. But a tsc output is not closed — it still carries bare
imports that something downstream must resolve, which reintroduces the install step
the constraint forbids. Bundling with esbuild is what makes the artifact standalone.
The dev loop keeps the best of both. The core plugins are resolved from source
(tsx, no build); the bundle is produced only for publication or delivery outside the
image. This
is the deliberate hybrid decision — core plugins load from source, delivered
plugins from dist/ —
and the host's loader handles both by switching .ts↔.js when it resolves a module.
There is, notably, no build-manifest.mjs or hand-rolled bundling script in this
path; the single meridian-plugin build command owns it end to end.
Distribution channels
A closed artifact can travel three ways, in increasing order of ceremony:
A registry. The recommended route. meridian-plugin publish uploads the built
dist/ to a store — a local/NFS directory or a GCS bucket (gs://…). The operator
never handles files: they declare the plugin in the instance manifest and the host
fetches it at boot. This is the natural fit for cloud and SaaS deployments, where
versioning and a shared source of truth matter. See
Publish to the registry and, on the operator
side, Set up a plugin registry.
A dropped folder. For on-prem, air-gapped hospital environments where a registry
is impractical, the artifact is the delivery: hand over dist/, the operator drops
it somewhere and points the host at it with PLUGINS_PATH (absolute paths,
,-separated; a plugin folder or a parent of several). No registry, no network — the
self-contained bundle is precisely what makes this possible. See
Declare and install plugins
and the environment variables reference.
Signed OCI bundles. The enterprise target, not yet built: bundles carried in an
OCI registry with signatures and capability metadata. It is called out here because
the dynamic import() in the loader is the deliberate seam where a trusted-JS
artifact could later be swapped for a WASM or sandboxed-worker one — the distribution
story is designed to grow a trust tier without reshaping the artifact.
The registry model
The registry is not a bespoke service. It is npm-like semantics layered over plain
file storage (libs/plugin-cli/src/registry/registry.ts),
which is what lets the same code back both a directory and a GCS bucket. A published
plugin is simply its dist/ files laid out under a versioned key:
@publisher/name/<version>/… e.g. @posos/demo/0.1.0/plugin.yaml
Three rules give this the properties you would expect of a package registry, without running one:
- Versions are immutable. Publishing over an existing version is refused; to
release again you bump
versioninplugin.yaml. An operator who pinned a version gets the same bytes forever. - The manifest is written last.
plugin.yamlis the completeness marker: an interrupted publish leaves partial files that downloads never see, because the marker isn't there yet. There is no half-published state. - Downloads are cached atomically. A required version is filled into a temp directory and renamed into the cache, so a torn download never becomes a torn cache.
Version selection reuses the standard semver package rather than any hand-rolled
comparison: listVersions sorts published versions with semver.compare, and
ensurePlugin picks the best match for a range with semver.maxSatisfying. The
platform does not reimplement what a well-tested library already does correctly.
How the host consumes it
At boot the host resolves plugins from two kinds of root, then loads all of them the same way:
- Instance-required plugins. The instance manifest lists
plugins:with version ranges and an optionalregistry:(falling back to theMERIDIAN_REGISTRYenv). For each, the host resolves the range against the registry and downloads the version into a local cache, whose folder becomes a discovery root (apps/api/src/registry.ts). A per-pluginpath:short-circuits the registry entirely — a local override for development. A listed plugin that cannot be resolved fails the boot loudly: it is a configuration error, like an invalid manifest. See the instance manifest reference. PLUGINS_PATHroots. Any absolute path in that env, for folder-drop delivery.
The loader discovers across every root and, crucially, treats a compiled
plugin exactly like a source-loaded one — the .ts↔.js switch is the only
difference it needs to bridge. Each plugin declares the SDK range it needs (sdk:);
the host compares it to its own PLUGIN_CONTRACT_VERSION (again via semver.satisfies) and
refuses and logs an incompatible plugin while the others still load. That single
compatibility gate is what the whole "SDK is the only external" arrangement buys: the
contract is checked at the door. The loading pipeline itself is described in
Plugin host loading.
What this design gives up, and why that's acceptable
Inlining runtime dependencies means shared libraries are duplicated across plugins rather than deduplicated by a package manager. On the scale a Meridian instance runs — a handful of plugins, not a browser bundle shipped to millions — that cost is negligible next to what it buys: a host that never installs, never resolves a foreign dependency graph, and can load a vendor's artifact on an isolated network. The trade is deliberate, and it is the same trade that makes the folder-drop and future signed-bundle channels possible at all.
The one thing the artifact deliberately does not contain is any way to grant
itself more trust than the host allows. Runtime code is loaded through a single
import() seam, the SDK is the only shared surface, and everything the plugin can do
is what the host's ports let it do — see Security posture
for how that boundary is drawn.
Related reading
- Autonomy and packaging — why a plugin depends on the SDK alone.
- Plugins, the concept — what a plugin contributes.
- Codegen internals — how the manifest projects to types, including cross-
dependsOnresolution. - Build and bundle · Publish to the registry — the step-by-step counterparts to this page.
- CLI reference · Plugin manifest reference.