3DN SDK
A proposed npm-installable host library for embedding the 3DN product builder in Shopify themes, storefronts, WordPress, ERP portals, and custom commerce sites. It packages the host contract every integration writes today (loading, login, license refresh, save before cart, customization records, close and logout) into one tested module. This page is the design for the unpublished package, not documentation of a published npm release.
@threedn/builder-host
Proposed name until verified on npm.
createBuilder()
Records host callbacks. Touches nothing until load or mount.
<threed-builder>
Loaded once per page, mounted into a host container.
Secure, scalable, developer friendly
These are requirements for the package, not aspirations: each one gets a test before 1.0. Where a requirement and a convenience conflict, the requirement wins.
Secure
The browser holds nothing that lets a visitor act as the shop or as another shopper.
- No secret ever reaches the browser. createBuilder() rejects an installation key or anything shaped like one with CONFIG_INVALID.
- Where the builder code loads from is owned by the SDK. Hosts choose only an approved environment and a version ("latest" or an exact semver), never a script URL, and anything else fails with CONFIG_INVALID.
- Site access and shopper identity stay separate tokens, so a guest never holds shopper rights and logout removes only the shopper layer.
- Tokens live only in memory and element attributes: never in a URL, in storage, or in a log line. Debug output redacts them.
- The SDK handles only events whose target is its own element, so a second builder or a script on the page cannot answer for it.
- Results that arrive after logout() or destroy() are discarded, so a slow mint cannot log a shopper back in.
- The resume helper accepts only a relative return path (starts with "/", not "//"), so the login redirect cannot become an open redirect.
- No eval, no innerHTML, no inline script: the SDK works under a strict CSP and Trusted Types. Subresource integrity is added once 3DN publishes a hash per release.
- Zero runtime dependencies. Published with npm provenance from CI, with 2FA required on the npm scope.
Scalable
Many builders per page, many pages per site, and many sites per release, with no extra load on 3DN or the host.
- The builder script loads once per page and is shared by every instance. Each instance keeps its own state and listeners.
- Single-flight callbacks: when several instances need a fresh license token at once, the SDK makes one session.refreshLicense() call and gives the result to all of them. One login round-trip runs at a time.
- Load on demand: load() can run on hover, on visibility, or on the Customize click, and mode: "viewer" uses the much smaller display-only bundle.
- The license token is cached once per installation on the host backend, and shopper tokens are minted only when a shopper needs them. The SDK never mints for a viewer.
- Small, tree-shakable ESM with sideEffects: false. Importing ./events pulls in types and constants only, not the loader.
- Version pinning per host gives controlled rollouts instead of every site moving to a new builder release at the same moment.
Developer friendly
A host integration is one config object and three backend endpoints, with mistakes caught early and explained.
- One createBuilder() call with typed callbacks. Every method returns a promise, and every failure is a BuilderError with a stable code, a safe message and a retryable flag.
- save() hides the clone-or-update question: when it resolves, the design is saved and any clone mapping is recorded.
- Configuration is checked up front. A missing required callback or an invalid option fails at createBuilder() or mount() with a message that names the option, not later inside an event handler.
- debug: true logs every lifecycle step and event, with tokens redacted, so most integration questions can be answered from the console.
- Safe to import in SSR frameworks (Next.js, Angular SSR, Remix). Browser work starts only in load() or mount().
- Framework-neutral core, with any React or Angular wrapper kept thin and built on the same core.
- A @3dn-builder/builder/testing entry point provides a fake <threed-builder> that emits contract events, so hosts can unit test their callbacks without loading the real builder.
- Copy-ready host examples for Express, WordPress and Shopify, plus a migration guide for the hand-written integrations in use today.
// createBuilder() refuses configuration that would leak a secret or load foreign code.
createBuilder({ installationKey: 'tdn_inst_…' }); // → CONFIG_INVALID
createBuilder({ scriptUrl: 'https://cdn.example/x.js' }); // → CONFIG_INVALID
createBuilder({ version: '../../evil' }); // → CONFIG_INVALID
// Allowed: an approved environment and an exact or 'latest' version.
createBuilder({ environment: 'staging', version: 'latest', session, login });Hosts with a Content-Security-Policy get the exact allowlist for their environment from 3DN during onboarding.
Npm is the primary distribution path
The proposed SDK ships as an npm package that host applications install into their own build pipeline. The package name below is the current design name and should be treated as unpublished until the 3DN package is verified.
npm install @3dn-builder/builderWhat the npm package contains
A small browser adapter, TypeScript declarations for the builder's events, and framework-neutral helpers for loading, mounting, the login round-trip, license refresh, saving, customization records, close, logout, teardown, and resuming a design after a login redirect.
What it does not contain
Installation keys, token minting, customer identity logic, cart logic, modal layout, login UI, or host backend endpoints. Those stay in the website or commerce backend.
Create, mount, save
createBuilder() creates an SDK controller and records host callbacks. It does not call those callbacks, load the builder script, or modify the page until the host calls load() or mount(). The host supplies three backend calls (session, login token, customization) and its own login UI; the SDK does everything else.
import { createBuilder } from '@3dn-builder/builder';
const builder = createBuilder({
environment: 'production',
version: 'latest',
session: {
// Site access, plus which 3DN product this shopper should open.
get: ({ hostProductId }) => backend.getBuilderSession(hostProductId),
refreshLicense: () => backend.refreshBuilderLicense(),
},
login: {
// Shopper identity, only when the builder asks for it. null on 401.
getTokens: () => backend.mintBuilderTokens(),
onRequired: (action) => showLogin(action), // 'logged-in' | 'redirected' | 'cancelled'
},
customization: {
// The SDK calls this when a save created the shopper's clone.
record: (link) => backend.recordCustomization(link),
},
onLicenseError: (error) => reportSetupError(error.code),
onCloseRequested: ({ hasUnsavedChanges }) => closeModal({ confirm: hasUnsavedChanges }),
});
const container = document.querySelector<HTMLElement>('#builder-container');
if (!container) throw new Error('Missing #builder-container');
// Mount with the host's own product reference, never a raw 3DN id.
await builder.mount({ target: container, hostProductId: shopProduct.id, mode: 'designer' });
customizeButton.addEventListener('click', () => {
showModal();
builder.open();
});
// Add to cart: when save() resolves, the design is saved and any clone
// mapping is already recorded. design.productId is the one for the cart.
const design = await builder.save();
await cart.add({ variantId, properties: { threednProductId: design.productId } });Host-owned container
The host supplies layout and visibility, for example <div id="builder-container" style="height: 600px"></div>. The element fills its container, so the container needs a real height. The SDK inserts only the builder element and attaches its event listeners.
Save means saved and linked
When save() resolves, the design is saved and, if that save created the shopper's own copy, the mapping is already recorded through customization.record(). The host does not need to know whether a save created a clone or updated one; design.productId is always the id for the cart line.
Site access and shopper identity stay separate
The SDK intentionally keeps site access and shopper identity separate. The session/license token lets a storefront load the builder. Shopper tokens are added only when the builder needs a logged-in shopper for a protected action such as save, upload, export, or share.
Site access: the license token
Proves the merchant website may load the 3DN builder. A broad, site-wide permission that every visitor uses, guests included. Minted by the merchant backend with its server-only installation key, cached once per installation, and returned by the session endpoint.
Shopper identity: bearer and refresh tokens
Proves which shopper is acting inside 3DN when they save, upload, export, share, or own private work. A narrow, per-shopper identity. Minted by the merchant backend only after it has authenticated the shopper and mapped them to a stable, private external reference. Never cached and replayed.
// Layer 1, site access. Every visitor, guest or not.
el.setAttribute('license-token', session.licenseToken);
// Layer 2, shopper identity. Only once a shopper is known.
el.setAttribute('refresh-token', tokens.refreshToken);
el.setAttribute('bearer-token', tokens.token);
// Logout or shopper switch: remove layer 2, keep layer 1.
el.removeAttribute('bearer-token');
el.removeAttribute('refresh-token');Mount with layer 1
Every mount starts from the site layer. Shopper tokens are added on top when a shopper logs in, and removed again on logout, without remounting the builder.
Never one combined token
A single token would make guest browsing, login, logout, switching shoppers and token refresh harder: each change of shopper state would mean replacing site access too.
The backend mints both
The browser SDK never mints anything. Tokens stay in memory and element attributes, and never appear in a URL, in storage, or in a log line.
The host names its product, the backend picks the 3DN product
The SDK should not force the storefront browser to know which 3DN product should open. The host passes its own product reference, and the host backend translates that into the correct 3DN product id for the current shopper.
Host product id
The merchant's own catalog product, for example Alpine's Shopify product. This is what the page passes to mount({ hostProductId }), and the SDK hands it to session.get().
3DN product id
The builder product 3DN actually opens: the global product the merchant embeds, or a shopper's clone of it. Only the session response names it; the page never hard-codes one.
Global product to shopper clone
- A global product is the original 3DN product the merchant embeds. Opening it never creates a clone.
- A guest, or a logged-in shopper who has never saved this product, opens the global product.
- The first save of a global product creates a shopper-specific 3DN clone. The builder and 3DN perform the clone, not the host, and the clone belongs to the shopper under the merchant's license.
- The builder then switches to the clone, so every later save updates it instead of creating another one.
- Opening a product that is already the shopper's clone and saving it updates that clone.
The first save of a global product creates a shopper-specific 3DN clone. The builder returns the cloned product id through the clone event. The host records that mapping so future sessions for the same shopper and host product can open the clone directly.
What the product detail page opens
The product detail page does not always open the global 3DN product. For a logged-in shopper who already saved a customized clone, the session should return that clone id. Otherwise, it returns the global product id. The decision belongs in the session response, not in browser logic.
| Shopper | Session returns |
|---|---|
| Guest | The global product |
| Logged in, no saved clone for this host product | The global product |
| Logged in, saved clone for this host product | The shopper's clone |
// GET /threedn/session?product=<hostProductId> (your path, your names)
// Guest, or a logged-in shopper with no saved clone: the global product.
{ "licenseToken": "…", "productId": "<global 3DN product id>", "viewToken": "…" }
// Logged-in shopper who already saved a clone for this host product.
{ "licenseToken": "…", "productId": "<shopper's clone id>",
"token": "…", "refreshToken": "…" } // optional fast pathRecording the clone mapping
The host does not handle the clone itself. It supplies customization.record(), a call to its customization endpoint, and the SDK calls it when a save created a clone. When save() resolves, that has already happened. The host's backend stores one row per shopper and host product: the shopper, the host product id, the global 3DN product id, and the clone id.
// What the SDK passes to customization.record() when a save created a clone.
link = {
hostProductId: 'alpine-mug-12oz', // your catalog reference
sourceProductId: '<global 3DN id>', // originalProductId
savedProductId: '<clone 3DN id>', // newProductId
};
// Your backend upserts one row per (shopper, hostProductId).
// The shopper comes from YOUR server session, never from the request body.
upsert({ shopper: req.user.id, ...link });Inside the SDK
For the people building the package. Save creates or updates the design. tdn-design-saved fires first and continues the save and add-to-cart flow; tdn-product-cloned carries the original-to-clone mapping and fires after the thumbnail upload. When the saved product differs from the one the SDK opened, it waits for the clone event and records the mapping before resolving.
// Inside the SDK: how save() settles. Hosts never see this branching.
el.dispatchEvent(new CustomEvent('tdn-save-request'));
startTimer(saveTimeoutMs); // 45 s by default, paused during a login round-trip
on('tdn-design-saved', async ({ productId, version }) => {
if (productId !== currentProductId) {
// The save created a clone. tdn-product-cloned follows the thumbnail
// upload, so wait for it before resolving.
const clone = await once('tdn-product-cloned');
pendingLink = {
hostProductId,
sourceProductId: clone.originalProductId,
savedProductId: clone.newProductId,
};
currentProductId = clone.newProductId; // later saves update the clone
}
if (pendingLink) {
try {
await config.customization.record(pendingLink);
pendingLink = null;
} catch (cause) {
// Saved, but not linked. A retried save() re-runs record() only.
return reject(new BuilderError('SAVE_FAILED', { reason: 'customization', retryable: true, cause }));
}
}
resolve({ productId, version }); // productId: always the one to put in the cart
});
on('tdn-design-save-failed', (detail) => reject(toBuilderError(detail)));
onTimeout(() => reject(new BuilderError('SAVE_TIMEOUT', { retryable: true })));Two callbacks, two different questions
session.get() opens or refreshes the builder session. login.getTokens() answers tdn-login-required. The initial session may already include shopper tokens as a fast path, but the login-token call remains the explicit answer whenever the builder asks for a logged-in shopper.
Session answers
- Which 3DN product should open?
- Is this website allowed to load the builder?
- Does this product need a private view token?
- Are shopper tokens already available, as an optional fast path?
Login answers
- The builder needs shopper identity for a protected action.
- Can the host provide 3DN shopper tokens now?
- Should the host redirect to its login page?
- Did the shopper cancel the login?
Each callback maps to one endpoint on the host backend. Paths are the host's choice. All three answer with Cache-Control: no-store, and the SDK never talks to the 3DN API with a key.
| Endpoint | SDK callback | Guest | Logged-in shopper |
|---|---|---|---|
| session | session.get(), session.refreshLicense() | License token, the product to open, and a view token for a private source. | The same, with their clone as the product when they have one. Optionally a pre-minted token pair. |
| login-token | login.getTokens() | 401: the SDK resolves null and asks the host to log in. | A fresh { token, refreshToken }, minted from the host's own session. No request body. |
| customization | customization.record() | 401. | Upserts the shopper, host product, global product and clone mapping. 3DN does not store this link. Called by the SDK only when a save created a clone. |
Designer, display-only, and private products
Not every embed is the full designer. Hosts can use display-only mode for previews and read-only views. When the product is private, the session must include a view token so the builder can load it safely.
Designer: mode: 'designer'
The full customization and editing experience: 2D and 3D editing, save, upload, export and share. The default.
Display-only: mode: 'viewer'
A read-only preview for product cards, galleries, read-only saved designs and other surfaces that do not edit. Orbit and zoom only, a much smaller download, no save, export or share. It still needs the site layer and never asks for a shopper login, so the host should not mint shopper tokens for it.
Private products need a view token
If the product to open is private, the session response must include viewToken. The SDK applies it before the builder loads the product, in both modes. A save of a private source product by a shopper creates their clone, which they then own, so a session that returns the clone drops the view token.
External export: opt-in, always answered
External export is an opt-in SDK feature. If enabled, the host must return the generated file result or the SDK must complete the request with a failure or timeout. The builder must never be left waiting indefinitely.
- Off by default. A host receives export requests only when it passes
externalExporttomount(). - Only for imported 3D model (GLB) products. Dieline products always export through 3DN.
- The host's
run()returns the files; the SDK sends the reply to the builder. - If
run()rejects, or takes longer thantimeoutMs(120 seconds by default, configurable per mount), the SDK aborts it and replies withEXPORT_FAILEDorEXPORT_TIMEOUT.
// Inside the SDK, only when the host passed externalExport. The builder has
// no timeout of its own, so the SDK always replies: files, or an error.
on('exportRequested', async (request) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), externalExport.timeoutMs ?? 120_000);
try {
const files = await externalExport.run(request, controller.signal);
reply({ files });
} catch {
const code = controller.signal.aborted ? 'EXPORT_TIMEOUT' : 'EXPORT_FAILED';
reply({ files: [], error: { code } }); // needs builder prerequisite 1
} finally {
clearTimeout(timer);
}
});Builder prerequisites for external export
tdn-export-completegains an optionalerrorfield, and the builder ends the export with its failure message when it is set. Today the reply only carries files, and an empty list ends the export without telling the shopper anything.- The builder accepts absolute file URLs from the host. Today it treats every file URL as a path on 3DN's own file storage, so a host pipeline cannot hand back its own files.
Host callbacks and responses
The callbacks are the host's own functions. They are not built into the SDK and are not passed through to the web component. Login uses its own login.getTokens() callback rather than calling session.get() again, because pre-minting shopper tokens in the session endpoint is optional under the host contract: a session endpoint that does not pre-mint would return no tokens after a login.
type BuilderEnvironment = 'production' | 'staging';
type BuilderVersion = 'latest' | `${number}.${number}.${number}`;
type BuilderMode = 'designer' | 'viewer';
/** What your session endpoint returns. Site access plus the product to open. */
interface BuilderSession {
licenseToken: string;
/** The 3DN product to open: the shopper's clone, else the global product. */
productId: string;
/** Required when the product to open is private. */
viewToken?: string;
/** Optional fast path: shopper tokens for a shopper your backend verified. */
token?: string;
refreshToken?: string;
}
/** Shopper identity, from your login-token endpoint. */
interface ShopperTokens {
token: string;
refreshToken: string;
}
type LoginOutcome = 'logged-in' | 'redirected' | 'cancelled';
interface CustomizationLink {
hostProductId: string;
sourceProductId: string;
savedProductId: string;
}
interface BuilderHostConfig {
environment?: BuilderEnvironment; // default 'production'
version?: BuilderVersion; // default 'latest'
saveTimeoutMs?: number; // default 45_000
session: {
get(request: { hostProductId: string }): Promise<BuilderSession>;
refreshLicense(): Promise<Pick<BuilderSession, 'licenseToken'>>;
};
login?: {
/** Answers tdn-login-required. null means 401: no shopper is logged in. */
getTokens(): Promise<ShopperTokens | null>;
/** Your login UX for the action the builder gated. */
onRequired(action: LoginRequiredAction): Promise<LoginOutcome>;
/** getTokens() failed (5xx, network). Default: 'cancel'. */
onMintFailed?(): Promise<'retry' | 'cancel'>;
};
customization?: {
record(link: CustomizationLink): Promise<void>;
};
onLicenseError?(error: BuilderError): void;
onCloseRequested?(detail: CloseRequestedDetail): void;
onStatusChange?(detail: BuilderStatusDetail): void;
/** Lifecycle logging to the console. Tokens are always redacted. */
debug?: boolean;
}
interface MountOptions {
target: HTMLElement;
/** Your own product reference, passed to session.get(). Never a 3DN id. */
hostProductId: string;
mode?: BuilderMode; // 'viewer' sets display-only="true"
designId?: string;
/** Opt-in, GLB products only. Off unless this is passed. */
externalExport?: {
run(request: ExportRequestedDetail, signal: AbortSignal): Promise<ExportFile[]>;
timeoutMs?: number; // default 120_000
};
} The event detail types are the builder's own. The SDK re-exports them from a separate ./events entry point so a host can type its own listeners without pulling in the loader.
// Re-exported from '@3dn-builder/builder/events', matching the builder.
type LoginRequiredAction = 'save' | 'screenshot' | 'export' | 'upload' | 'share' | 'session';
type LicenseErrorCode = 'ORIGIN_NOT_ALLOWED' | 'LICENSE_TOKEN_INVALID';
type DesignSaveFailureReason =
| 'conflict' | 'forbidden' | 'unauthorized' | 'login-cancelled'
| 'installation-inactive' | 'license' | 'error';
interface DesignSavedDetail { productId: string; version: number }
interface DesignSaveFailedDetail { productId?: string; reason: DesignSaveFailureReason; retryable: boolean }
interface ProductClonedDetail { originalProductId: string; newProductId: string; name: string; asCopy: boolean }
interface CloseRequestedDetail { productId?: string; hasUnsavedChanges: boolean }
interface BuilderStatusDetail { status: 'idle' | 'opening' | 'loading' | 'ready' | 'error'; step?: string; message?: string; error?: string }
interface ExportRequestedDetail { productId: string; formats: string[]; surfaces: string[]; mode?: string }
interface ExportFile { url: string; format: string; surface: string }
interface ExportCompleteDetail { files: ExportFile[] }
// Proposed builder addition (prerequisite 1): error?: { code: 'EXPORT_FAILED' | 'EXPORT_TIMEOUT' }Runtime methods
Event binding and attribute order in mount()
The SDK binds listeners before connecting the builder element and applies required attributes before load begins. This prevents missed events and avoids starting the builder with incomplete session data. Within the shopper pair, refresh-token goes before bearer-token, because setting the bearer token resumes a pending action.
- Create the builder element.
- Attach event listeners.
- Set the 3DN product id from the session.
- Set the license token.
- Set the view token, if the session returned one.
- Set the shopper refresh and bearer tokens, if the session returned them.
- Set mode flags such as display-only and external-export.
- Insert the element into the target container. Loading starts here.
// What mount() does, in this order. Nothing is connected until step 8.
await loader.load(environment, version); // once per page
const session = await config.session.get({ hostProductId });
const el = document.createElement('threed-builder'); // 1
attachListeners(el); // 2 no early event is missed
el.setAttribute('product', session.productId); // 3
el.setAttribute('license-token', session.licenseToken); // 4
if (session.viewToken) el.setAttribute('view-token', session.viewToken); // 5
if (session.token) { // 6
el.setAttribute('refresh-token', session.refreshToken); // refresh first,
el.setAttribute('bearer-token', session.token); // then bearer
}
if (designId) el.setAttribute('design-id', designId);
if (mode === 'viewer') el.setAttribute('display-only', 'true'); // 7
if (externalExport) el.setAttribute('external-export', 'true');
target.appendChild(el); // 8 load starts hereopen() and the save timeout
open() is included in version one for hosts that mount or preload the builder before showing the editing UI, which is common in modal and Customize-button flows. save() defaults to a 45-second timeout (saveTimeoutMs): long enough for heavy designs and slow networks, short enough that a shopper is never stuck. On timeout it rejects with a retryable SAVE_TIMEOUT.
Close is the host's decision
The builder's close button emits tdn-close-requested and does nothing else. The SDK calls onCloseRequested(); the host asks first when hasUnsavedChanges is true, then hides the modal or calls destroy(). A host that shows the builder full screen should also make the browser back button ask first instead of dropping unsaved edits.
Every login request gets one answer
When a guest presses Save (or screenshot, export, upload, share), or the builder cannot refresh its session, it fires tdn-login-required once and waits. The SDK must end every round-trip with either user tokens on the element or tdn-login-cancelled, except when the host redirected to a login page: then it sends neither.
// How the SDK answers tdn-login-required: exactly one answer per request.
async function onLoginRequired(action) {
if (pending) return; // one round-trip at a time
pending = true;
try {
for (;;) {
let tokens;
try {
tokens = await config.login.getTokens();
} catch {
// 5xx or network: offer a retry, never a login.
if ((await config.login.onMintFailed?.()) === 'retry') continue;
return cancel(action);
}
if (tokens) return setShopperTokens(el, tokens); // resumes the action
const outcome = await config.login.onRequired(action);
if (outcome === 'logged-in') continue; // dialog login: mint now
if (outcome === 'redirected') return; // never cancel on redirect
return cancel(action); // dispatches tdn-login-cancelled
}
} finally {
pending = false;
}
}Token refresh is the builder's job
The builder spends its refresh token and retries by itself when the access token (about 2 hours) expires. Only when that fails does it fire tdn-login-required with action: 'session'. The SDK runs no refresh loop of its own and never calls the refresh endpoint. Rotated tokens live only inside the builder and are not written back to the attributes, so the SDK never reads tokens off the element.
License refresh is never a login
On tdn-license-token-required the SDK calls session.refreshLicense() and sets license-token. The builder retries the held request once. If no token arrives within 30 seconds, or the new one is rejected too, the builder fires tdn-license-error.
Logout
logout() removes the shopper layer, bearer-token then refresh-token, in one tick. The builder logs out locally, drops any pending login-gated action, and revokes its session on 3DN. It keeps working with the license token. To switch users, log out, then call refreshSession().
Stale results are dropped
A getTokens() or session.get() result that resolves after logout() or destroy() is ignored, so a slow mint cannot log a shopper back in after they logged out.
Login by redirect
Most platforms log in on a separate page. Before firing tdn-login-required for a save, the builder has already written the design to the device, marked "save pending". The host redirects with a relative return path carrying the tdn_resume=1 marker and resolves 'redirected'. Back on the product page the SDK strips the marker, the host mounts the same source product, its session now includes user tokens, and the builder restores the draft and runs the pending save by itself.
import { consumeResumeMarker, hasDraft } from '@3dn-builder/builder';
// Back from the login page: reopen the same host product.
// The builder restores the draft and runs the pending save itself.
if (consumeResumeMarker()) await openDesigner();
// Shopper came back later: offer to continue the draft kept on this device.
if (hasDraft(sourceProductId)) showResumeButton(openDesigner);Bridge the current builder contract
The SDK wraps the web component's existing events and adds no undocumented signals. The SDK listens on the element it created and dispatches its own events on that same element, so two builders on one page never answer each other.
| Event | Direction | Payload | What the SDK does |
|---|---|---|---|
| tdn-login-required | builder → host | { action } | Runs the login round-trip with login.getTokens() and login.onRequired(); answers with tokens or tdn-login-cancelled. |
| tdn-license-token-required | builder → host | — | Calls session.refreshLicense() and sets license-token. Never a login prompt. |
| tdn-license-error | builder → host | { code } | Calls onLicenseError() with a fatal LICENSE_ERROR. Does not mint again. |
| tdn-design-saved | builder → host | { productId, version } | Continues the save, and with it the add-to-cart flow. If the saved product is a new clone, the SDK waits for tdn-product-cloned before resolving save(). |
| tdn-design-save-failed | builder → host | { productId?, reason, retryable } | Rejects save() with a BuilderError: LOGIN_CANCELLED or SAVE_FAILED, carrying the reason and retryable flag. |
| tdn-product-cloned | builder → host | { originalProductId, newProductId, name, asCopy } | Carries the original → clone mapping. The SDK calls customization.record({ hostProductId, sourceProductId, savedProductId }) and switches to the clone, before save() resolves. |
| tdn-close-requested | builder → host | { productId?, hasUnsavedChanges } | Calls onCloseRequested(). The builder stays open until the host hides or destroys it. |
| tdn-status-change | builder → host | { status, step?, message?, error? } | Calls onStatusChange(). Load progress only: it never means a save happened. |
| exportRequested | builder → host | { productId, formats, surfaces, mode? } | Opt-in: only with externalExport, GLB products only. Runs externalExport.run() and always replies. |
| tdn-open-builder | host → builder | — | Sent by open(). |
| tdn-login-cancelled | host → builder | { action? } | Sent when the login will not happen. A pending save fails with login-cancelled. |
| tdn-save-request | host → builder | — | Sent by save(). |
| tdn-export-complete | host → builder | { files, error? } | The required reply: the files, or an EXPORT_FAILED or EXPORT_TIMEOUT error. The error field is a builder prerequisite. |
Saved is not thumbnail-ready
tdn-design-saved fires as soon as the save is stored, before the thumbnail refresh, and after a save as copy (then followed by tdn-product-cloned). tdn-status-change with ready follows every load step and must not be read as a save. A "thumbnail ready" signal would need a new builder event first.
Hosts call methods, not events
This table is the SDK's internal wiring. A host uses save(), open(), logout() and the callbacks it passed to createBuilder(), and never has to parse a raw DOM event.
One error shape for every failure
Every SDK failure should resolve to a stable BuilderError. Hosts should not need to parse raw DOM events to understand whether a failure is retryable, fatal, user-cancelled, or an export timeout. Each error carries a stable code, a message that is safe to show or log, and the builder's original reason for debugging.
type BuilderErrorCode =
| 'CONFIG_INVALID' // bad option, or a secret passed to the browser
| 'NOT_IN_BROWSER' // load() or mount() called during SSR
| 'LOAD_FAILED' // the builder script did not load
| 'VERSION_CONFLICT' // another builder version already owns the page
| 'SESSION_FAILED' // session.get() rejected
| 'LICENSE_ERROR' // tdn-license-error: fatal for this mount
| 'LOGIN_CANCELLED' // the protected action ended because login was cancelled
| 'SAVE_FAILED' // tdn-design-save-failed, see reason
| 'SAVE_TIMEOUT' // no save outcome within saveTimeoutMs
| 'EXPORT_FAILED' // externalExport.run() rejected
| 'EXPORT_TIMEOUT' // externalExport.run() exceeded its timeout
| 'DESTROYED'; // the instance was destroyed mid-call
class BuilderError extends Error {
readonly code: BuilderErrorCode;
/** Safe to show in host UI or logs. Never contains a token. */
readonly message: string;
readonly retryable: boolean;
/** The mount cannot continue until the host refreshes or recreates it. */
readonly fatal: boolean;
/** The builder's own reason, kept for debugging. */
readonly reason?: DesignSaveFailureReason | LicenseErrorCode | 'customization';
/** The raw event detail, tokens redacted. */
readonly cause?: unknown;
}
try {
await builder.save();
} catch (e) {
if (!(e instanceof BuilderError)) throw e;
if (e.code === 'LOGIN_CANCELLED') return; // shopper chose not to log in
showMessage(e.message, { retry: e.retryable });
}License error is fatal for the mount
ORIGIN_NOT_ALLOWED (this hostname is not on the license) and LICENSE_TOKEN_INVALID (rejected twice) both become a fatal LICENSE_ERROR passed to onLicenseError. The SDK does not mint again. The mount stays unusable until the host refreshes or recreates the session, and the host should tell its own backend, because only the shopper's browser ever sees this error.
Cancelled login is not a crash
When the shopper does not log in, the protected action ends with LOGIN_CANCELLED and the builder keeps working. A host usually just returns quietly; the builder has already told the shopper.
Save failures say whether to retry
save() rejects with SAVE_FAILED or SAVE_TIMEOUT and a retryable flag. The host keeps Add to cart disabled and offers a retry only when it is true.
Exports never hang
An external export that fails or times out is completed with an error reply, so the builder is never stuck waiting.
Error codes
| Code | When | Retryable | Fatal |
|---|---|---|---|
| CONFIG_INVALID | A bad option, a secret, or a script URL passed to createBuilder() or mount(). | no | yes |
| NOT_IN_BROWSER | load() or mount() called during server-side rendering. | no | no |
| LOAD_FAILED | The builder code did not load. The next call tries again. | yes | no |
| VERSION_CONFLICT | Another builder version already owns this page. | no | yes |
| SESSION_FAILED | session.get() rejected. | yes | no |
| LICENSE_ERROR | tdn-license-error. Fatal for this mount until the host refreshes or recreates the session. | no | yes |
| LOGIN_CANCELLED | A protected action ended because the shopper did not log in. The builder keeps working. | yes | no |
| SAVE_FAILED | tdn-design-save-failed, or the save succeeded but customization.record() failed. | per reason | no |
| SAVE_TIMEOUT | No save outcome within saveTimeoutMs (45 seconds by default), outside a login round-trip. | yes | no |
| EXPORT_FAILED | externalExport.run() rejected. The SDK replied with an error. | yes | no |
| EXPORT_TIMEOUT | externalExport.run() took longer than its timeout (120 seconds by default). The SDK replied with an error. | yes | no |
| DESTROYED | destroy() ran while the call was pending. | no | no |
Builder save reasons
The builder's tdn-design-save-failed reason is kept on the error as reason, together with the event's own retryable flag.
| Reason | SDK code | Meaning |
|---|---|---|
| conflict | SAVE_FAILED | Someone saved first. The builder shows its conflict dialog. |
| forbidden | SAVE_FAILED | This user may not write to the product. The builder offers request access or save as copy. |
| unauthorized | SAVE_FAILED | Login recovery ran and the save was still refused. |
| login-cancelled | LOGIN_CANCELLED | The shopper closed the login, or the host could not mint shopper tokens. |
| installation-inactive | SAVE_FAILED | The host installation is not active. A setup problem, not a user error. |
| license | SAVE_FAILED | This page is not licensed. A LICENSE_ERROR follows. |
| error | SAVE_FAILED | Network or server failure. |
| customization | SAVE_FAILED | SDK only: the design was saved but customization.record() failed. A retried save() records again without saving again. |
Package exports and versioning
The SDK package version and the builder version solve different problems. The SDK version covers the adapter API installed by the host. The builder version selects which builder release the adapter loads.
{
"name": "@3dn-builder/builder",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./events": {
"types": "./dist/events.d.ts",
"import": "./dist/events.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
}
},
"dependencies": {},
"files": ["dist", "README.md", "LICENSE"]
}No script URLs from hosts
The SDK must not accept arbitrary script URLs from hosts. It exposes only approved environment and version options, and internally maps those to SDK-owned staging and production builder locations. Any other value fails with CONFIG_INVALID.
Builder release selection
version: 'latest' loads the current builder release for the chosen environment. An exact version loads that release, which gives a host controlled upgrades instead of moving with every release. 3DN confirms which versions are supported.
One builder version per page
A page has one custom element registry, so the first builder version loaded defines <threed-builder> for the whole page. A second controller asking for a different exact version rejects with VERSION_CONFLICT instead of silently running the first one.
Dependency strategy
No runtime dependencies. Plain TypeScript types and browser utilities, so Angular, React, Liquid, WordPress, and vanilla JavaScript hosts use the same package. Every entry point is safe to import during server-side rendering.
Build the SDK in small modules
BuilderLoader
Map the environment and version to the SDK-owned builder location, load it once per page, retry after a failure, guard against SSR, wait for custom element registration, and refuse a second, different version.
BuilderInstance
Own a mounted element, bind listeners before connection, apply attributes in the documented order, isolate instance state, and clean up on destroy.
SessionController
Keep the two token layers apart: apply the site layer from the session, add and remove the shopper layer as login state changes, run the login round-trip and license refresh (one in-flight refresh shared by every instance of a controller), and drop stale results after logout or destroy.
SaveController, ExportBridge and errors
Settle save() only once the design is saved and any clone mapping is recorded, with a configurable 45-second timeout paused during login. Always reply to an external export. Normalize every failure into a BuilderError.
Package build, test, publish plan
Add the SDK as a buildable library in the 3DN monorepo, next to the builder, and take event names and detail types from the builder's own shared constants so the package cannot drift from the element. Emit ESM and .d.ts files. Unit test loader, session, save, clone recording, export and error behavior against a fake element; add browser integration tests for mount, login (dialog and redirect), license refresh, first-save clone, later saves, logout and destroy against a real builder. Ship the fake element as the ./testing entry point, keep a size budget in CI, and publish from CI with npm provenance once release automation and semver policy are in place.
First merchant: Alpine
The first SDK migration target should be Alpine because it covers the core product identity, login, clone, and checkout flows: host product ids, a global 3DN product, shopper clones, separate session and login tokens, add to cart, and display-only or private viewing where needed.
Package name
The proposed package name, @threedn/builder-host, is acceptable pending npm scope and name availability verification.
Still open
- The two builder prerequisites for external export (an error reply, and absolute file URLs).
- The staging builder location the SDK maps
environment: 'staging'to, confirmed before release. - Whether the SDK offers a confirm-on-back helper for full-screen hosts, or leaves it to each host.
- Whether the SDK reports
ORIGIN_NOT_ALLOWEDto the host backend itself, through an optional callback. - Whether to add a builder "thumbnail ready" event before 1.0, so hosts stop reading load progress as a save.