@3dn-builder/builder
Embed the 3DN product builder in any storefront. The SDK loads the builder, runs the login round-trip, refreshes the license, saves and reports every save. Your site supplies a session endpoint, a login-token endpoint and its own login UI.
Install
npm install @3dn-builder/builder
ESM, zero runtime dependencies. Entry points: ., ./events (types and constants only), ./testing (a fake <threed-builder>).
Create, mount, destroy
import { createBuilder } from '@3dn-builder/builder';
const builder = createBuilder({
session: {
get: ({ hostProductId }) => api.getBuilderSession(hostProductId),
refreshLicense: () => api.refreshBuilderLicense(),
},
login: {
getTokens: () => api.mintBuilderTokens(), // null on 401
onRequired: (action) => showLogin(action), // 'logged-in' | 'redirected' | 'cancelled'
},
onSaved: (saved) => showSavedDesign(saved.productId),
onImagesSaved: (saved) => refreshGallery(saved.productId),
onLicenseError: (error) => reportSetupError(error.code),
onCloseRequested: () => closeModal(), // the builder already asked about unsaved changes
});
const instance = await builder.mount({ target: container, hostProductId: 'alpine-mug' });
const design = await instance.save(); // design.productId goes on the cart line
instance.destroy();
createBuilder() touches nothing on the page, so it is safe during server-side rendering; the builder loads on load() or mount(). The target needs a real height.
The builder loads at the latest release unless you pass version: '1.2.3'. Every released version stays online, so a pinned version keeps loading, but it only keeps working while the 3DN API still accepts what it sends. Stay on 'latest' unless you need to hold a release back.
| Method | Behavior |
|---|---|
load() | Optional preload, once per page. |
mount(options) | { target, hostProductId, mode?: 'designer' | 'viewer', designId?, loader?: 'container' | 'page', externalExport?, observabilityContext? } → instance. |
open() | Shows the builder UI after a hidden mount. |
save() | Resolves { productId, version } after the save, any clone record, onSaved and the images refresh. 45 s timeout. |
refreshSession() | Calls session.get() again and applies the result. |
refreshLicense() | One shared license refresh for every mounted builder. |
logout() | Removes the shopper tokens, keeps the license token. Late login results are dropped. |
destroy() | Removes the builder and its listeners; pending calls reject with DESTROYED. |
loader sets what the loading and license screens cover. The default, 'container', covers only target, so the rest of your page stays usable while the builder loads. Pass 'page' to block the whole browser window, for example when the builder opens as a full-screen modal. Dialogs and the image editor always cover the whole window.
observabilityContext is the builder's observability-context: a JSON object string naming your app, tenant, site, pseudonymous user and request, plus your page's sentry-trace and baggage, so the builder's own error reports say where they happened. It is set before the element connects; instance.setObservabilityContext(json) replaces it later, for example once your page's Sentry has a trace. The keys and their rules are in the integration guide, section 6.3.
The controller's methods act on the most recently mounted builder. With several builders on a page, call the instance mount() returned.
Session
session: {
get(request: { hostProductId: string }): Promise<BuilderSession>;
refreshLicense(): Promise<{ licenseToken: string }>;
}
interface BuilderSession {
licenseToken: string;
productId: string; // the shopper's saved copy, else the global product
viewToken?: string; // required for a private product
token?: string; // optional shopper tokens, always together
refreshToken?: string;
}
hostProductId is your own reference; your backend decides which 3DN product it opens. Installation keys stay on your server: createBuilder() rejects anything that looks like one, and any option that names where the builder loads from.
Login
login: {
getTokens(): Promise<{ token: string; refreshToken: string } | null>; // null on 401
onRequired(action: LoginRequiredAction): Promise<'logged-in' | 'redirected' | 'cancelled'>;
onMintFailed?(): Promise<'retry' | 'cancel'>; // getTokens() threw; default 'cancel'
}
Login by redirect: send the shopper to loginReturnPath() after login (check it with isSafeReturnPath()), return 'redirected', and reopen the builder when consumeResumeMarker() is true; the builder finishes the pending save. hasDraft(productId) says whether a draft is kept on this device.
Saving
onSaved?(saved: { hostProductId: string; productId: string; version: number }): void;
onImagesSaved?(saved: { hostProductId: string; productId: string; version: number }): void;
customization?: {
record(link: {
hostProductId: string;
sourceProductId: string;
savedProductId: string;
asCopy: boolean;
}): Promise<void>;
};
onSaved fires once for every successful save, from save() or from the builder's own Save button. A first save makes the shopper's copy: productId is then the copy, and onSaved fires only after the copy exists and customization.record() resolved. It never fires for a failed save or a failed record; the next save records again.
onSaved comes before the product's images (its thumbnail and camera-angle screenshots) are refreshed. Read the images on onImagesSaved: it fires once they show the design at version, after that save's onSaved. It does not fire when the images could not be updated; the product keeps its previous ones. save() resolves only after the images are refreshed or have failed, so a host that destroys the builder after await save() never cuts the upload short. This needs a builder that sends tdn-images-saved, the one released with this SDK or later; with an older pinned version, save() times out.
A shopper's first save names the copy after the source and when it was saved, for example "Mug Grey – 19 Oct, 14:32", so several designs from one product can be told apart.
asCopy says what the copy means. true: the shopper asked for one ("Save as copy", also offered after a save conflict), so store it as a new design and keep the original. false: Save had to fork a product the shopper doesn't own, so the copy replaces the open design.
Orders
When an order is placed, lock the ordered design from your backend with your installation key:
POST {API_BASE}/plugin/products/{productId}/lock
x-installation-key: {INSTALLATION_KEY}
{ "orderRef": "1042-A", "version": 7 }
Send the productId and version from onSaved that you stored on the cart line. A 409 means the shopper saved again after that version, or another order already locked it. A locked design can never change again (there is no unlock): it opens read-only, with Download and Save as copy, and a save on it fails with reason: 'locked'. To change an ordered design, cancel the order and let the shopper order a copy.
POST {API_BASE}/plugin/products/{productId}/view-only-link returns { url }: a link that opens the design in a bare viewer (the model in 3D, with the 2D / Split / 3D switch, the animation slider, the camera angles and Download print files, and no editing tools) and downloads its print files without a login, for example for a print house. Nothing can be saved or copied through it.
Features
features?: {
download?: boolean; // default true: Download print files (and a design area's SVG)
share?: boolean; // default true: Share a link to the design
save?: boolean; // default true: Save, Save as copy, version history and the save state
feedback?: boolean; // default true: Report a problem
};
Set download: false or share: false to hide that action from shoppers, in the desktop and the phone layout, for example when shoppers should not take the artwork away. Set save: false for a try-it-out editor where nothing is kept: the shopper can design, switch between 2D and 3D and use every tool, but there is no Save, no Save as copy, no version history and no save state; save() still works for the host. Set feedback: false to hide "Report a problem", where the shopper describes what went wrong and 3DN receives it with a recording of the session. All are shown unless you turn them off. Any other key, or a value that is not a boolean, is a CONFIG_INVALID error.
Errors
Every failure is a BuilderError: code, a message safe to show, retryable, fatal, and the builder's reason.
| Code | When |
|---|---|
CONFIG_INVALID | Bad option, or a secret or script URL passed in. |
NOT_IN_BROWSER | load() or mount() during SSR. |
LOAD_FAILED | The builder did not load; the next call retries. |
VERSION_CONFLICT | Another builder version already runs on the page. |
SESSION_FAILED | session.get() failed or returned an invalid session. |
LICENSE_ERROR | The license was refused. Fatal for the mount; onLicenseError is called. |
LOGIN_CANCELLED | The shopper did not log in. |
SAVE_FAILED | The save failed, or customization.record() failed (reason: 'customization'). |
SAVE_TIMEOUT | No outcome within saveTimeoutMs. |
EXPORT_FAILED, EXPORT_TIMEOUT | externalExport.run() failed or timed out. |
DESTROYED | destroy() ran while the call was pending. |
A SAVE_FAILED with reason: 'locked' means an order locked the design; it is not retryable, and the builder offers Save as copy.
A SAVE_FAILED with reason: 'outdated' means the builder on the page is older than the server, which no longer takes its saves. It is not retryable; the builder offers the shopper a page reload, which loads the latest builder and carries the unsaved design across where it can.
The builder saves only when asked: its Save button, Ctrl/Cmd+S, its close prompt, or your save(). Until then the unsaved work is kept as a draft on the device (per user, never sent to the server).
onCloseRequested({ productId?, hasUnsavedChanges, discarded }) fires when the shopper closes the builder. With unsaved changes the builder first asks Save / Don't save / Cancel: Save closes once the save and its images are done, Don't save closes with discarded: true (do not ask again), Cancel stays open. Your page needs no unsaved-changes prompt of its own.
Other callbacks: onStatusChange({ status, step?, message?, error? }) (load progress, never a save). status: 'error' also fires when part of the builder's own code failed to download; the builder then shows a Reload page button.
Angular
@Component({ selector: 'app-designer', template: '<div #target style="height: 100%"></div>' })
export class DesignerComponent implements OnDestroy {
@Input({ required: true }) productRef!: string;
@ViewChild('target', { static: true }) target!: ElementRef<HTMLElement>;
private instance?: BuilderInstance;
constructor() {
afterNextRender(async () => {
const { createBuilder } = await import('@3dn-builder/builder'); // browser only
const builder = createBuilder({ session, login, onSaved: (s) => this.saved(s) });
this.instance = await builder.mount({ target: this.target.nativeElement, hostProductId: this.productRef });
});
}
ngOnDestroy() {
this.instance?.destroy();
}
}
Testing your integration
import { installFakeBuilder, findFakeBuilders } from '@3dn-builder/builder/testing';
installFakeBuilder(); // before mount(): no real builder is loaded
const instance = await builder.mount({ target, hostProductId: 'mug' });
const saving = instance.save();
await findFakeBuilders()[0].emitSavedAsClone('clone-1');
await saving; // customization.record(), onSaved and onImagesSaved have run
emitSaved() and emitSavedAsClone() send the images outcome as the builder does; emitImagesSaved() and emitImagesSaveFailed() send one on its own.
Developing this package
npx nx test builder-host
npx nx lint builder-host
npx nx build builder-host # dist/libs/builder-host
Event names and payload types come from @three-dn/constants and are bundled into the published files. The build stamps the version from the root package.json; the publish-sdk job in .github/workflows/deploy.yml publishes each production release.