Skip to content

Bundle extensions

A bundle is the wrapper extension type for distributing several extensions as a single npm package. The bundle itself is registered with type: 'bundle', and inside it sits an entries array describing the nested extensions: interfaces, displays, hooks, operations, and so on. Bundles let entries share dependencies, share a build, and ship under one name.

Reach for a bundle when:

  • Several extensions implement one feature together (a custom collection plus the interface that edits its values plus the hook that validates writes plus the operation that processes them).
  • A set of extensions share heavy dependencies and you do not want to install them three or four times.
  • You want a single version, a single changelog, and a single npm package for a feature that spans the app and the API.

Stay with separate single-type packages when:

  • The extensions are unrelated and only happen to be authored by the same person.
  • One of the extensions is meaningfully large and would muddle a smaller one’s release cadence.
  • You expect operators to mix and match — not every consumer needs every extension.

A bundle is a packaging decision, not an architectural one. The individual extensions inside it work the same way they would as standalone packages.

Run the scaffolder and choose bundle as the extension type:

Terminal window
npm init cairncms-extension

The scaffolder creates an empty bundle package. From there, use the SDK CLI to add entries.

Inside the bundle directory, run:

Terminal window
cairncms-extension add

The CLI prompts for the entry’s type, name, and language, then:

  • creates src/<name>/... with the appropriate template files
  • adds an entry to the cairncms:extension.entries array in package.json

Existing extensions can also be added by hand: copy the source into the bundle’s src/ directory and add a matching entry to the manifest.

The bundle’s package.json carries cairncms:extension with type: "bundle", an entries array, and a path object pointing to the built output:

{
"cairncms:extension": {
"type": "bundle",
"path": { "app": "dist/app.js", "api": "dist/api.js" },
"entries": [
{
"type": "interface",
"name": "color-grid",
"source": "src/color-grid/index.ts"
},
{
"type": "hook",
"name": "validate-colors",
"source": "src/validate-colors/index.ts"
},
{
"type": "operation",
"name": "apply-palette",
"source": {
"app": "src/apply-palette/app.ts",
"api": "src/apply-palette/api.ts"
}
}
],
"host": "^1.0.0"
}
}

A bundle always builds two outputs (dist/app.js and dist/api.js), even when its entries are all on one side. The unused side is essentially empty but still present.

Each entry in the entries array describes one nested extension. There are two shapes depending on the entry’s type:

  • App or API entries{ type, name, source } where source is a string pointing to the entrypoint file. Used for interface, display, layout, module, panel, hook, and endpoint.
  • Hybrid entries{ type, name, source } where source is { app, api } pointing to two entrypoint files. Used for operation.

type must be a real extension type. bundle cannot nest inside bundle. Convention-based customizations (custom migrations, email templates) are not extension types and cannot live inside a bundle either.

Run npm run build from the bundle’s root, same as any other extension. The SDK CLI handles the multi-entry build. It walks entries, runs Rollup against each one, and combines the outputs into the two split bundles.

The --watch flag works the same way it does for single-extension packages and rebuilds whenever any entry’s source changes.

To remove an extension from a bundle:

  1. Delete the entry’s directory under src/.
  2. Remove the matching entry from cairncms:extension.entries.

Rebuild to update the dist outputs.

A bundle that ships a custom interface plus the action hook that fires when fields using that interface are written.

package.json (relevant excerpt):

{
"cairncms:extension": {
"type": "bundle",
"path": { "app": "dist/app.js", "api": "dist/api.js" },
"entries": [
{ "type": "interface", "name": "tracker", "source": "src/tracker/index.ts" },
{ "type": "hook", "name": "tracker-audit", "source": "src/tracker-audit/index.ts" }
],
"host": "^1.0.0"
}
}

src/tracker/index.ts:

import { defineInterface } from '@cairncms/extensions-sdk';
import Component from './tracker.vue';
export default defineInterface({
id: 'tracker',
name: 'Tracker',
icon: 'analytics',
component: Component,
types: ['string'],
options: null,
});

src/tracker-audit/index.ts:

import { defineHook } from '@cairncms/extensions-sdk';
export default defineHook(({ action }, { logger }) => {
action('items.update', (meta) => {
if (meta.payload && Object.keys(meta.payload).some((k) => k.startsWith('tracker_'))) {
logger.info({ collection: meta.collection, keys: meta.keys }, 'tracker field updated');
}
});
});

Build with npm run build. The resulting package can be installed once and brings both extensions along.

  • Creating extensions covers the toolchain in full, including how to install bundles.
  • The individual extension type pages cover the API and minimum example for each entry type a bundle can contain.