@structdk/extension-sdk
v1.2.0
Published
Framework agnostic SDK for building Struct extensions
Readme
@structdk/extension-sdk
SDK for building extensions for Struct PIM. Extensions run as iframes embedded inside the Struct PIM UI and communicate with the host via a postMessage-based API. This SDK abstracts that messaging layer into a simple, typed API.
Full developer documentation: docs.struct.com
Installation
npm install @structdk/extension-sdkSetup
Create an SDK instance using createStructSDK. Optionally pass the hostOrigin of your Struct PIM instance to restrict message acceptance to that origin only.
import { createStructSDK } from '@structdk/extension-sdk';
const struct = createStructSDK({ hostOrigin: 'https://your-struct-instance.struct.com' });If
hostOriginis not provided, all origins are accepted and a warning is printed to the console.
The returned object exposes actions, events, and a destroy() cleanup function.
Actions (struct.actions)
Actions are messages sent from your extension to Struct PIM.
getContext
Requests the current context from Struct PIM. Returns a promise that resolves with the context payload for the current extension point. This is the primary way to retrieve the session context (slug, language, user, entity info, etc.).
import { createStructSDK } from '@structdk/extension-sdk';
import type { TabContextPayload } from '@structdk/extension-sdk';
const struct = createStructSDK();
const ctx = await struct.actions.getContext<TabContextPayload>();
console.log('Entity:', ctx.entityType, ctx.entityId);
console.log('Slug:', ctx.slug);An optional GetContextOptions object can be passed to override the default timeout (10 000 ms):
const ctx = await struct.actions.getContext<TabContextPayload>({ timeoutMs: 5000 });The context payload type depends on the extension point. Use the appropriate typed payload for full type safety:
| Extension point | Payload type | Extra fields |
|-------------------|-------------------------------|-----------------------------------------------|
| Tab | TabContextPayload | entityId, entityType |
| Section | SectionContextPayload | entityId, entityType |
| Sidebar widget | SidebarWidgetContextPayload | entityId, entityType |
| Property | PropertyContextPayload | entityId, entityType |
| Exporter | ExporterContextPayload | selectedEntityIds, selectedEntityType |
| Search action | SearchActionContextPayload | selectedEntityIds, selectedEntityType |
| Page | PageContextPayload | (none) |
| Widget | WidgetContextPayload | widgetUid, isSettingsMode |
| Import | ImportContextPayload | (none) |
All payload types extend BaseContextPayload, which includes: messagingVersion, slug, currentLanguage?, currentUser?, currentSegments?.
Widget context
A widget extension is rendered once per dashboard widget, and the same app can be added to a dashboard
more than once. WidgetContextPayload carries two extra fields so an app can tell those renders apart
and store configuration against a single one of them:
import type { WidgetContextPayload } from '@structdk/extension-sdk';
const ctx = await struct.actions.getContext<WidgetContextPayload>();
if (ctx.isSettingsMode) {
renderConfigurationForm(ctx.widgetUid);
} else {
renderWidget(await loadConfiguration(ctx.widgetUid));
}widgetUididentifies the dashboard widget the app is rendered in. It is stable for the lifetime of that widget and differs between two copies of the same app on one dashboard, which makes it a suitable key for per-widget configuration stored on your own backend. A modal opened withopenModalinherits the widget context of the extension that opened it.isSettingsModeistruewhile the app renders inside the widget settings surface - both the configure step shown when a user adds the widget and the settings dialog of an existing widget - andfalseon the dashboard itself. Render your configuration UI when it istrue. The value is fixed for the lifetime of the iframe and never changes underneath you.
Three things to be aware of when you store configuration per widget:
widgetUidis an identifier, not a credential. It reaches your page overpostMessage, so your backend cannot verify that a caller really is that widget. Authorize requests withgetToken()and scope stored configuration by the token'stenantSlug, treating the uid as untrusted input.A widget uid can refer to a widget that is never saved. A user can configure your app while adding a widget and then leave without saving the dashboard, and Struct sends no signal when a widget is deleted. Expect configuration you will never hear about again, and do not rely on a deletion event.
Struct's own Cancel and Update buttons do not roll back your configuration. They apply only to the widget properties Struct owns. If your settings UI writes immediately, that write has already happened by the time the user presses Cancel - give your settings UI its own save affordance so the user understands when their configuration is stored.
Configuration keyed on widgetUid is shared exactly as widely as the dashboard the widget lives on: a
widget on a personal dashboard is seen by one user, while a widget on a shared dashboard is seen by
everyone who has that dashboard.
getToken
Requests a short-lived signed token (JWT) from Struct PIM for the current app. Returns a promise that resolves with the token payload.
The token is minted server-side and signed with the app's client secret. It can be verified with the same client secret to confirm it originates from a genuine Struct embedded session for your tenant.
const { token, expiresAt, issuedAt } = await struct.actions.getToken();The resolved TokenProvidedPayload contains:
| Field | Type | Description |
|-------------|----------|--------------------------------------|
| token | string | Signed HS256 JWT |
| expiresAt | number | Expiry as Unix time in seconds |
| issuedAt | number | Issued-at as Unix time in seconds |
An optional GetTokenOptions object can be passed to override the default timeout (10 000 ms):
const { token } = await struct.actions.getToken({ timeoutMs: 5000 });The promise rejects as soon as Struct PIM reports it cannot mint a token, carrying the reason it gave. If the host does not answer at all, the promise rejects on timeout with a description of what the SDK could observe — whether the page is embedded, the configured host origin, and whether another script or browser extension has replaced window.postMessage or window.addEventListener.
openModal
Opens a modal inside the Struct PIM UI, rendering a URL in an iframe.
struct.actions.openModal({
id: 'my-modal',
url: 'https://your-extension.example.com/modal',
placement: 'center', // 'center' | 'left' | 'right'
size: 'medium', // 'small' | 'medium' | 'large'
});closeModal
Closes an open modal by its ID.
struct.actions.closeModal({ id: 'my-modal' });closeHostContainer
Asks the Struct PIM host to close the dialog that is currently hosting your extension iframe (e.g. the export-entities dialog or a search-action dialog). Use this when your extension has finished its work and the surrounding host dialog should be dismissed.
struct.actions.closeHostContainer();Only supported on the Exporter and SearchAction extension points — these are the only extension points whose iframes are embedded inside a dismissible host dialog. On any other extension point the host fires onActionRejectedEvent with reason Action "CLOSE_HOST_CONTAINER" is not supported for extension point type "<type>".
Not to be confused with
closeModal, which only closes a modal that the same extension previously opened viaopenModal.closeHostContainercloses the host dialog that contains the extension iframe itself.
showSnackbarMessage
Displays a snackbar notification in the Struct PIM UI.
struct.actions.showSnackbarMessage({
message: 'Changes saved successfully',
placement: 'bottom', // 'top' | 'bottom'
isError: false,
durationMs: 3000, // optional
});resizeContainer
Manually sets the iframe container height in pixels. Supported on Tab, Section, Property, and Sidebar Widget extension points.
struct.actions.resizeContainer({ height: 400 });refreshEditor
Asks Struct PIM to refresh the editor that is currently hosting the extension iframe. Re-fetches the editor model, breadcrumbs and thumbnail without a page reload. If the user has unsaved changes, the host prompts them to confirm before discarding their work — if they cancel, no refresh happens and the action completes silently.
struct.actions.refreshEditor();Only supported on the Tab, Section, Property, and SidebarWidget extension points — these are the only ones that live inside an editor. On any other extension point the host fires onActionRejectedEvent with reason Action "REFRESH_EDITOR" is not supported for extension point type "<type>".
enableAutoResize
Automatically adjusts the iframe height based on content using a ResizeObserver. Only one auto-resize observer can be active at a time. Supported on Tab, Section, Property, and Sidebar Widget extension points.
struct.actions.enableAutoResize({
maxHeight: 800, // optional — cap the height
debounceMs: 200, // optional — debounce delay (default 100ms)
});setIsDirtyState
Notifies Struct PIM whether the extension currently has unsaved changes. While any extension reports isDirty: true, the surrounding editor will show its standard "you have unsaved changes" confirmation dialog if the user attempts to navigate away (which would destroy the iframe and lose those changes).
Call with isDirty: true when the user starts editing, and with isDirty: false once changes are saved or discarded.
struct.actions.setIsDirtyState({ isDirty: true });
// ...later, after saving:
struct.actions.setIsDirtyState({ isDirty: false });Only supported on Tab, Section, and Property extension points. On any other extension point the host fires onActionRejectedEvent with reason Action "SET_IS_DIRTY_STATE" is not supported for extension point type "<type>".
navigate
Navigates the Struct PIM host to a relative path within the PIM. The host resolves the path against the current tenant origin — extensions do not need to know the tenant host.
struct.actions.navigate({ path: '/pim/search' });path must be a relative, app-absolute path starting with a single / (e.g. /pim/search). Absolute URLs (https://…) and protocol-relative paths (//host) are rejected by the host so an extension cannot drive the top window to a foreign origin — a rejected navigation surfaces an error to the user.
Use
navigateToEntityinstead when navigating to the editor for a specific entity — it resolves the route from the entity type and ID, so your extension doesn't depend on the host's route structure.
navigateToEntity
Navigates the Struct PIM host to the editor for a given entity. The host resolves the URL from entityType + entityId — extensions do not need to know the host's route structure.
struct.actions.navigateToEntity({
entityType: 'Product',
entityId: '7d3f...e91',
});Supported entity types: Product, Variant, VariantGroup, Category, Asset, GlobalList, GlobalListValue.
Events (struct.events)
Events are messages sent from Struct PIM to your extension. Each listener returns an unsubscribe function.
onEntityChangedEvent
Fired whenever the entity the extension is associated with is saved/updated. Relevant for tab, section, sidebar widget, and property extension points.
const unsubscribe = struct.events.onEntityChangedEvent((payload) => {
console.log(payload.entityType, 'with id', payload.entityId, 'changed');
});onLanguageChangedEvent
Fired whenever the user switches the active language in Struct PIM. Use this to re-fetch localized data or update the UI.
struct.events.onLanguageChangedEvent((payload) => {
console.log('Language changed to:', payload.currentLanguage);
});onSegmentChangedEvent
Fired whenever the active segment selection changes in Struct PIM.
struct.events.onSegmentChangedEvent((payload) => {
console.log('Segments changed:', payload.currentSegments);
});onActionRejectedEvent
Fired when the host rejects an action (e.g., a resize on an unsupported extension point).
struct.events.onActionRejectedEvent((payload) => {
console.warn(`Action rejected: ${payload.rejectedAction} — ${payload.reason}`);
});Cleanup
Call destroy() to unsubscribe all event listeners and stop any active auto-resize observer.
struct.destroy();Quick start
A minimal extension that retrieves its context and listens for changes:
import { createStructSDK } from '@structdk/extension-sdk';
import type { TabContextPayload } from '@structdk/extension-sdk';
// 1. Create SDK instance (optionally lock to a specific origin)
const struct = createStructSDK({ hostOrigin: 'https://your-struct-instance.struct.com' });
// 2. Request context from the host
const ctx = await struct.actions.getContext<TabContextPayload>();
console.log('Entity:', ctx.entityType, ctx.entityId);
console.log('Slug:', ctx.slug);
// 3. Auto-resize the iframe to fit content
struct.actions.enableAutoResize({ maxHeight: 800 });
// 4. Register event listeners for ongoing changes
struct.events.onEntityChangedEvent((payload) => {
console.log('Entity updated:', payload.entityId);
});
struct.events.onLanguageChangedEvent((payload) => {
console.log('Language:', payload.currentLanguage);
});
// 5. Clean up when done
struct.destroy();Types
All payload types are exported from the package root:
import type {
// SDK options
StructSDKOptions,
// Context payloads
BaseContextPayload,
TabContextPayload,
SectionContextPayload,
SidebarWidgetContextPayload,
PropertyContextPayload,
ExporterContextPayload,
SearchActionContextPayload,
PageContextPayload,
WidgetContextPayload,
ImportContextPayload,
// Event payloads
EntityChangedPayload,
LanguageChangedPayload,
SegmentChangedPayload,
ActionRejectedPayload,
TokenProvidedPayload,
// Action payloads
OpenModalPayload,
CloseModalPayload,
CloseHostContainerPayload,
ShowSnackbarMessagePayload,
ResizeContainerPayload,
RefreshEditorPayload,
SetIsDirtyStatePayload,
NavigatePayload,
NavigateToEntityPayload,
// Options
AutoResizeOptions,
GetContextOptions,
GetTokenOptions,
} from '@structdk/extension-sdk';The StructEntityType enum is also exported:
import { StructEntityType } from '@structdk/extension-sdk';
// StructEntityType.Product | .Category | .Variant | .VariantGroup | .AssetVersioning
The SDK version is available at runtime via ExtensionSdkVersion:
import { ExtensionSdkVersion } from '@structdk/extension-sdk';
console.log('SDK version:', ExtensionSdkVersion);Changelog
[1.2.0] - 2026-10-01
Added
WidgetContextPayloadnow carrieswidgetUidandisSettingsMode, so a widget extension can tell which dashboard widget it renders in and whether it is being shown on the dashboard or in the widget settings surface. Together they let an app store configuration scoped to a single widget on its own backend.widgetUidis a render-slot identifier rather than a credential: authorize withgetToken()and scope stored configuration by the token'stenantSlug. See "Widget context" in the README for the full rules, including the fact that a widget uid can refer to a widget that is never saved.
[1.1.0] — 2026-08-11
Changed
getToken()andgetContext()now reject as soon as Struct PIM reports it cannot serve the request, instead of waiting out the timeout. The rejection carries the host's reason, e.g.Struct PIM rejected getToken: Failed to mint a token for this app instance. A rejection addressed to a different action is ignored.- Timeout errors now describe what the SDK could observe rather than guessing at a cause:
whether the page is embedded at all, the configured host origin, and whether another
script or browser extension has replaced
window.postMessageorwindow.addEventListener. The previous wording ("is the SDK running inside a Struct PIM iframe?") was reported even when the SDK was correctly embedded.
[1.0.8] — 2026-06-03
Added
struct.actions.navigate({ path })— navigate the Struct PIM host to a relative path within the PIM (e.g./pim/search). The host resolves the path against the current tenant origin, so extensions do not need to know the tenant host. Absolute and protocol-relative URLs are rejected. Supported on all extension points.struct.actions.getToken()— request a short-lived signed token (JWT) from Struct PIM for the current app. The token is minted server-side and signed with the app's client secret, and can be verified with the same secret to confirm it originates from a genuine Struct embedded session. Resolves with{ token, expiresAt, issuedAt }. Supported on all extension points.
[1.0.7] — 2026-05-26
Added
struct.actions.navigateToEntity({ entityType, entityId })— navigate the Struct PIM host to the editor for a given entity. Supported entity types:Product,Variant,VariantGroup,Category,Asset,GlobalList,GlobalListValue.struct.actions.refreshEditor()— ask the host to refresh the editor that is hosting the extension iframe. Re-fetches the editor model, breadcrumbs and thumbnail without a page reload. If the user has unsaved changes the host prompts them to confirm before discarding their work. Supported onTab,Section,Property, andSidebarWidgetextension points.struct.actions.setIsDirtyState({ isDirty })— notify the host whether the extension currently has unsaved changes, so the surrounding editor can show its standard unsaved-changes confirmation dialog when the user attempts to navigate away. Supported onTab,Section, andPropertyextension points.
[1.0.6] — 2026-04-08
Added
struct.actions.closeHostContainer()— ask the host to dismiss the dialog hosting the extension iframe. Supported on theExporterandSearchActionextension points.
[1.0.5] — 2026-03-23
Changed
- README rewritten to document the post-
createStructSDKAPI surface (actions, events, context payload types, cleanup).
[1.0.4] — 2026-03-19
Added
createStructSDK(options?)factory — returns the{ actions, events, destroy }object that is now the SDK's primary entry point. Replaces the previous module-level action/event imports.struct.actions.enableAutoResize({ maxHeight?, debounceMs? })— auto- adjusts the iframe height based on content via aResizeObserver.struct.events.onActionRejectedEvent(handler)— fired when the host rejects an action (e.g. an unsupported action for the current extension point type).struct.destroy()— unsubscribes all event listeners and stops any active auto-resize observer.StructSDKOptions.hostOrigin— when provided, the SDK only accepts messages from that origin. If omitted, all origins are accepted and a warning is logged.
[1.0.3] — 2026-03-18
Changed
- Breaking: Initialisation switched from push-based to pull-based.
Extensions now call
struct.actions.getContext()(returns a Promise) instead of subscribing toonInitEvent. This removes the race between iframe load and the host's init message.
Removed
onInitEvent— replaced bygetContext.
[1.0.1] — 2026-03-12
Fixed
- Internal messaging and packaging fixes following the initial publish.
[1.0.0] — 2026-03-10
Added
- Initial public release.
openModal,closeModal,showSnackbarMessage,resizeContaineractions.onInitEventfor receiving the extension's startup context from the host (replaced in 1.0.3).
License
MIT
