Documentation / Développement / Explications / Bundling and distribution

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 every module: path rewritten from its .ts source 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 via tsc --emitDeclarationOnly when a tsconfig is present. These matter only for other plugins that dependsOn this one and need its ./types export at their build time; a leaf plugin without a tsconfig simply emits none.
  • i18n bundles, the sidecar i18n/*.yaml files, 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 version in plugin.yaml. An operator who pinned a version gets the same bytes forever.
  • The manifest is written last. plugin.yaml is 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:

  1. Instance-required plugins. The instance manifest lists plugins: with version ranges and an optional registry: (falling back to the MERIDIAN_REGISTRY env). 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-plugin path: 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.
  2. PLUGINS_PATH roots. 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.

75 documents8 sectionssource : /docs · généré au build