npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-signal

Quick 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 no sku; the editor refetches whichever variant it is showing for that itemId/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 an itemIds (all) group, variant columns for a skus (variants) group. Each entry is a top-level component id ('brand') or a /-separated nested path ('chunk/piece/text'), the same format the host's getShapeConfig walks. 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) {
    // ...
  }
}