@3dn-builder/builder

中文文档
0.713.0发布于 2026/11/07MITTypeScriptES Module零依赖近 30 天下载 1万

Embed the 3DN product builder in any storefront: loading, login, license refresh, save and customization records.

原始使用文档
文档目录11 节
  1. Install
  2. Create, mount, destroy
  3. Session
  4. Login
  5. Saving
  6. Orders
  7. Features
  8. Errors
  9. Angular
  10. Testing your integration
  11. Developing this package

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

MethodBehavior
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.

CodeWhen
CONFIG_INVALIDBad option, or a secret or script URL passed in.
NOT_IN_BROWSERload() or mount() during SSR.
LOAD_FAILEDThe builder did not load; the next call retries.
VERSION_CONFLICTAnother builder version already runs on the page.
SESSION_FAILEDsession.get() failed or returned an invalid session.
LICENSE_ERRORThe license was refused. Fatal for the mount; onLicenseError is called.
LOGIN_CANCELLEDThe shopper did not log in.
SAVE_FAILEDThe save failed, or customization.record() failed (reason: 'customization').
SAVE_TIMEOUTNo outcome within saveTimeoutMs.
EXPORT_FAILED, EXPORT_TIMEOUTexternalExport.run() failed or timed out.
DESTROYEDdestroy() 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.

版本与发布记录

@3dn-builder/builder 共发布 77 个版本,首次发布于 2026/10/10;过去 12 个月发布了 77 个版本。

版本发布日期距今
0.713.0latest2026/11/07今天
0.712.02026/11/07今天
0.711.02026/11/071 天前
0.709.02026/11/061 天前
0.708.02026/11/052 天前
0.707.02026/11/052 天前

关于 @3dn-builder/builder 的常见问题

@3dn-builder/builder 是什么?

Embed the 3DN product builder in any storefront: loading, login, license refresh, save and customization records.

如何安装 @3dn-builder/builder?

在项目目录运行 npm install @3dn-builder/builder,或使用 pnpm add / yarn add / bun add。

@3dn-builder/builder 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@3dn-builder/builder 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@3dn-builder/builder 可以商用吗?

@3dn-builder/builder 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@3dn-builder/builder 的下载量有多少?

根据 npm 官方下载统计,@3dn-builder/builder 在 2026/10/05 – 2026/11/03 这 30 天内被下载 10,123 次,日均约 337 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@3dn-builder/builder 还在维护吗?

最新版本 0.713.0 发布于 2026/11/07(今天);至今共发布 77 个版本,其中过去 12 个月发布了 77 个;首次发布于 2026/10/10。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。