@crystallize/plugin-signal
v0.15.0
Published
Typed JSON-RPC 2.0 messaging between a Crystallize plugin iframe and the parent app, with zod-validated method contracts.
Readme
@crystallize/plugin-signal
Typed JSON-RPC 2.0 messaging between a Crystallize plugin iframe and the parent app, with zod-validated method contracts.
Install
pnpm add @crystallize/plugin-signalQuick start
import { createChannel, createWindowTransport, type DataGridPayload } from '@crystallize/plugin-signal';
const channel = createChannel({
transport: createWindowTransport(),
handlers: {
setDataGrid: (channel, payload) => renderGrid(payload),
},
});
// Tell the parent we are mounted — it will respond with setDataGrid
channel.notify('ready', undefined);
// Send edits back
function onUserSavesEdits(payload: DataGridPayload) {
channel.notify('updateDataGrid', payload);
}
function onUserClicksClose() {
channel.notify('closePluginDialog', undefined);
channel.dispose();
}createWindowTransport() defaults to the plugin-iframe context:
| Option | Default | Meaning |
| --------------- | ------------------- | ------------------------------------------------------------------- |
| target | globalThis.parent | The Crystallize app window |
| targetOrigin | '*' | Passed to postMessage |
| allowedOrigin | validParentOrigin | Only accept incoming messages from known Crystallize parent origins |
| source | globalThis | The plugin iframe's own window |
Override any field if you need different behavior.
Methods
Every method has a zod schema for params and result in src/schemas.ts. Both sides validate on send and receive — invalid params produce a JSON-RPC -32602 Invalid params error.
| Method | Direction | Params | Result |
| ------------------------------ | -------------- | --------------------------- | --------------------------- |
| ready | child → parent | — | — |
| setDataGrid | parent → child | DataGridPayload | — |
| setEntityContext | parent → child | EntityContext | — |
| updateDataGrid | child → parent | DataGridPayload | — |
| closePluginDialog | child → parent | — | — |
| notifyHeight | child → parent | HeightPayload | — |
| notifyWidth | child → parent | WidthPayload | — |
| promoteToDialog | child → parent | PromoteToDialogPayload | — |
| demoteFromDialog | child → parent | — | — |
| navigateTo | child → parent | NavigateToParams | — |
| updateItemComponent | child → parent | UpdateItemComponentParams | UpdateItemComponentResult |
| refetchItem | child → parent | RefetchTargetPayload | — |
| refetchItemComponents | child → parent | RefetchTargetPayload | — |
| refetchItemVariantComponents | child → parent | RefetchTargetPayload | — |
| closeSplitView | child → parent | — | — |
notifyHeight lets the plugin request a target iframe height in integer pixels — the host can use it to resize the iframe to fit its content.
notifyWidth lets the plugin request a target iframe width in integer pixels — the host can use it to resize the iframe (e.g. a sidebar) to fit its content.
Dialog promotion
promoteToDialog lets a plugin ask the host to render its iframe as a centered floating dialog. demoteFromDialog returns it to its original slot. Both signals are fire-and-forget and direction child → parent.
// child side
channel.notify('promoteToDialog', { width: 800, height: 600 });
// later
channel.notify('demoteFromDialog', undefined);// parent side
const channel = createChannel({
transport,
handlers: {
promoteToDialog: (_, { width, height }) => host.promote({ width, height }),
demoteFromDialog: () => host.demote(),
},
});PromoteToDialogPayload is { width?: number; height?: number }. Either dimension may be omitted — the host falls back to a sensible default and clamps oversized values to the viewport.
The same iframe element keeps running across promote/demote. Only the wrapper's CSS positioning and size change — no reload, no loss of in-iframe state.
Hosts that don't support promotion (e.g. plugins already running inside plugin-dialog) treat both signals as silent no-ops.
Updating an item component
updateItemComponent is a request/response signal: plugins write a value to any component path on an item or variant. The host injects the value into the matching rendered leaf component if the catalogue editor is open at that entity/language/sku.
const result = await channel.call('updateItemComponent', {
itemId: 'abc123',
language: 'en',
componentId: 'summary-chunk/0/summary',
input: { singleLine: { text: 'New title' } },
});
if (!result.applied) {
// result.reason: 'NOT_RENDERED' | 'TYPE_MISMATCH'
// result.message: string
}Result (one of):
{ applied: true }— value was injected into the matching rendered leaf.{ applied: false, reason: 'NOT_RENDERED', message }— no leaf currently rendered at the requested(itemId, language, sku ?? '', componentId).{ applied: false, reason: 'TYPE_MISMATCH', message }— a leaf is rendered at that path, but the input's discriminator does not match the leaf's component type.
The input shape is ComponentContentInput from @crystallize/schema/pim (with the inner componentId omitted) — every leaf shape (singleLine, richText, numeric, boolean, datetime, files, images, videos, location, selection, itemRelations, propertiesTable, paragraphCollection) plus every structural shape (contentChunk, componentChoice, componentMultipleChoice, piece).
Refetching catalogue data
refetchItem, refetchItemComponents, and refetchItemVariantComponents let a plugin ask the catalogue editor to re-fetch its data after the plugin has changed something server-side. All three are fire-and-forget notifications sharing the same payload:
// child side
channel.notify('refetchItem', { itemId: 'abc123', language: 'en' });
channel.notify('refetchItemComponents', { itemId: 'abc123', language: 'en' });
channel.notify('refetchItemVariantComponents', { itemId: 'abc123', language: 'en' });RefetchTargetPayload is { itemId: string; language: string } (both required, non-empty).
refetchItem— re-fetches the base item (name, tree position, etc.).refetchItemComponents— re-fetches the item-level components.refetchItemVariantComponents— re-fetches the variant-level components of the variant currently displayed in the editor. The signal carries nosku; the editor refetches whichever variant it is showing for thatitemId/language.
Silently ignored when not applicable. The host only acts if the catalogue editor is currently open on that itemId in that language. If the user is viewing a different item, a different language, or is not in the catalogue at all, the signal is dropped with no effect and no response. Any plugin iframe can fire these — not only one rendered in the catalogue.
Entity context
setEntityContext is fired by the host every time the user navigates within the Crystallize app after the iframe has rendered. The initial value is still part of the encrypted payload posted to the iframe at mount.
// child side
const channel = createChannel({
transport: createWindowTransport(),
handlers: {
setEntityContext: (_channel, ctx) => {
if (ctx.entity === 'catalogue' && ctx.itemId) {
// ctx.itemType, ctx.variantSku, ctx.itemLanguage are available here
}
},
},
});EntityContext is a discriminated union on the entity field. Each variant has only the optional identifier and sub-state fields that apply to its section. The host always populates the base fields — pathname, searchParams, placement, tenantIdentifier, language — on every variant.
| Variant | Notable optional fields |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| dashboard | — |
| catalogue | itemType, itemId, variantSku, subView, itemLanguage, itemsType, selectedRowIds |
| topics | topicId, mode |
| grids | gridId, mode |
| orders | orderId, subscriptionId, mode |
| customers | identifier, mode |
| flows | identifier, flowType, mode |
| insight | section |
| fulfilment | pipelineId, mode |
| subscription-contracts | contractId, mode |
| assets | imageKey |
| changelog | documentId |
| special-prices | section |
| marketplace | path |
| apps | identifier |
| settings | section |
| unknown | — |
zEntityContext is exported for callers that want runtime validation outside of the protocol pipeline.
DataGridPayload is generic across every grid in the Crystallize app (explorer, customers, orders, price-lists, promotions, etc.) — its DataGridCellValue discriminated union covers every cell kind (text, number, boolean, uri, bubble, image-text, assets, videos, rich-text, number-unit, selection, topics, item-relation, meta-data). Adding new cell kinds is non-breaking for existing plugins.
Navigation
navigateTo asks the host to navigate the Crystallize app to a specific entity. The signal is fire-and-forget and direction child → parent.
// navigate to an item
channel.notify('navigateTo', { entity: 'item', id: 'abc123' });
// open in a new tab
channel.notify('navigateTo', { entity: 'order', id: 'ord-1', target: '_blank' });
// open with split views alongside the primary target
channel.notify('navigateTo', {
entity: 'item',
id: 'abc123',
splits: [{ entity: 'item', id: 'def456', view: 'edit' }],
});
// open the Lightbox page, feeding it shape-keyed groups of items in one message
channel.notify('navigateTo', {
entity: 'lightbox',
area: 'catalogue',
groups: [
{
shapeIdentifier: 'product',
itemIds: ['abc123', 'def456'],
skus: ['abc123-blue'], // optional: showing skus opens the group's variants view, restricted to these SKUs
componentIdentifiers: ['brand', 'chunk/piece/text'], // seed columns (product columns here; variant columns when skus are set)
},
{ shapeIdentifier: 'article', itemIds: ['ghi789'] },
],
});NavigateToParams is a discriminated union on the entity field. Supported entity variants and their required identifiers:
| entity | Required identifier field(s) |
| -------------------- | --------------------------------------------------------------------------- |
| item | id or externalReference (at least one); optional language |
| productVariant | sku; optional language |
| image | key |
| order | id |
| fulfilmentPipeline | id |
| customer | identifier |
| grid | id |
| topic | id; optional language |
| priceList | identifier |
| lightbox | area ('catalogue'); groups (non-empty), each with shapeIdentifier + non-empty itemIds |
All variants except lightbox accept the optional top-level field target: '_blank' and a splits array of catalogue split targets (item or productVariant, each with an optional view string) opened alongside the primary target. Split views currently only render in the catalogue, so other entities are not yet valid split targets.
The lightbox target is the combined navigate-and-feed signal: it opens /lightbox/<area> and hands the page groups of items keyed by shape, rendering one shape's items at a time (the host shows a shape-tab selector when there is more than one group). Each group carries:
itemIds— the items to fetch for that shape (non-empty, required).skus— optional. When present, the group opens in its variants view, restricting variant rows to these SKUs. When absent, the group shows its items (the all view).componentIdentifiers— optional. Seeds the selectable component columns: product columns for anitemIds(all) group, variant columns for askus(variants) group. Each entry is a top-level component id ('brand') or a/-separated nested path ('chunk/piece/text'), the same format the host'sgetShapeConfigwalks. Builtin columns use the host defaults.
The view (all vs variants) is derived per group from skus — there is no activeTab field, and the host shows no items-type selector for the lightbox. target/splits do not apply to the lightbox target.
Split views
A plugin rendered in a catalogue split can close its own split slot. The signal is fire-and-forget, direction child → parent, and only acted on when the plugin is actually running inside a catalogue split — any other iframe that fires it is a silent no-op.
// close the split this plugin lives in
channel.notify('closeSplitView', undefined);// parent side (the split host)
const channel = createChannel({
transport,
handlers: {
closeSplitView: () => host.closeSplit(splitId),
},
});Errors
channel.call() rejects with an RpcError whose code follows the JSON-RPC 2.0 spec, plus the package-specific extensions:
| Code | Meaning |
| -------- | ------------------------------------------------- |
| -32700 | Parse error |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params (failed zod validation on receive) |
| -32603 | Internal error |
| -32000 | Handler threw |
| -32002 | Timeout |
| -32003 | Channel disposed |
import { RpcError, RpcErrorCode } from '@crystallize/plugin-signal';
try {
await channel.call('updateDataGrid', payload);
} catch (error) {
if (error instanceof RpcError && error.code === RpcErrorCode.Timeout) {
// ...
}
}