@crystallize/plugin-signal
v0.6.0
Published
Typed JSON-RPC 2.0 messaging between a Crystallize plugin iframe and the parent app, with zod-validated method contracts.
Downloads
949
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 | — | — |
| updateItemComponent | child → parent | UpdateItemComponentParams | UpdateItemComponentResult |
| refetchItem | child → parent | RefetchTargetPayload | — |
| refetchItemComponents | child → parent | RefetchTargetPayload | — |
| refetchItemVariantComponents | child → parent | RefetchTargetPayload | — |
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.
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) {
// ...
}
}