Catalog3D Embed is a third-party tag. It is dropped into stores this repository
has never seen, next to CSS it does not control, loaded by tag managers it
cannot configure, and it has to survive all of that without the merchant
debugging it. Every decision below follows from that.
This page records what the loader does and why, including the changes made in
the 1.3.0 pre-publication hardening pass and the things that were deliberately
left alone.
The shape of the public API
One iframe. Product identity is delivered by a versioned postMessage
handshake, not in the frame URL. The handle is frozen and has two methods.
Configuration is immutable after mounting.
The reason is blast radius. Everything the merchant page can reach is
everything an attacker who compromises the merchant page can reach, and
everything Catalog3D can never change without breaking stores. A room picker, a
scene collection, a job API, or a generic update() would each turn a private
implementation detail into a permanent public commitment. docs/api-reference.md
lists the absent APIs explicitly so their absence reads as a decision rather
than an oversight.
Packaging
The npm package now ships a module build. main pointed at the IIFE bundle
while type was module, so the package parsed as ESM with no exports and
import { mount } from "@catalog3d/embed" failed with does not provide an
export named 'mount'. scripts/build.mjs now emits both artifacts:
dist/catalog3d.js — minified IIFE for the <script> tag. This is what a
store puts in its page.
dist/catalog3d.mjs — unminified ESM for bundlers, reachable through
exports. Left unminified so a downstream stack trace is readable; the
consumer's bundler minifies it anyway.
files now includes docs, because the README links to those pages and every
one of those links was a 404 on npmjs.com. publishConfig.access is set because
a scoped package does not publish publicly without it.
The browser build no longer uses esbuild's globalName. The source installs
window.Catalog3D itself and refuses to replace a loader that is already there.
Tag managers, A/B tools, and storefront apps inject the same third-party tag
twice as a matter of routine. Each copy carries its own mountedTargets
registry, so a second copy overwriting the global would silently defeat
TARGET_IN_USE and let two loaders fight over one container. The first loader
on the page wins. A globalName wrapper assignment would have undone that guard
after the module body ran, which is why it had to go.
Surviving the host page
Loader-owned geometry is declared !important. Inline !important
outranks author !important, which is the only thing that survives a store's
global reset — iframe { width: auto }, * { margin: 8px }, a CSS framework
normalizing every block element. Merchants still control size and placement
completely, through the target element they own; the loader only defends the box
it created inside that target.
The iframe continues to delegate no unused device permissions. The current
extension uses file upload and ordinary pointer interaction; repository
searches find no camera stream, sensor, WebXR, or fullscreen API use. Granting
those features would expand the iframe's capability without enabling current
behavior. If a future room feature needs one, add only that feature together
with its runtime implementation, production Permissions Policy, tests, and
security docs.
loading="eager", deliberately. Lazy loading would be the better citizen
for a below-the-fold embed, but the mount promise and the 20-second ready
timeout both depend on the handshake starting immediately. A lazily loaded embed
below the fold would time out before the shopper ever scrolled to it.
referrerPolicy="no-referrer". Catalog3D does not need the merchant's page
URL, and the origin check it does need arrives through postMessage, which the
browser attests to and the page cannot forge.
Failure modes that were leaking
The initialization retry loop stopped leaking. The loader re-posts init
every 500ms until the frame answers. A ready arriving after the frame had
already been ready hit an early return before the clearInterval, so any
frame reload left the loop running for the lifetime of the host page — measured
at five posts every three seconds, forever. Iframes reload more than expected:
moving a node in the DOM reloads it, and the renderer restores crashed frames.
The interval is now cleared before the already-ready check.
A frame reload now rejects in-flight removal intents. They were addressed to
frame state that no longer exists and could never be answered, so each one sat
until its 10-second timeout. They fail immediately with INTERNAL_ERROR
instead.
requestRemoval enforces one intent at a time. The API reference always
said so; the loader did not, and three concurrent calls put three intents on the
wire. A second call now rejects synchronously with BUSY, which is the code the
docs already described for this case.
The declarative element
<catalog3d-room> had two lifecycle defects, both from the same guard —
connectedCallback returned early whenever the element had any child.
Re-parenting no longer blanks the element. Moving a node fires
disconnectedCallback immediately followed by connectedCallback. The old code
tore down on the first and then refused to remount on the second because the
pending mount's wrapper was still a child, leaving a permanently empty element
after any carousel, tab switch, or framework reorder inside the 20-second
handshake window. Teardown is now deferred by one task and cancelled if the
element reconnects, which is the standard move-versus-remove idiom.
Placeholder content no longer disables the embed. <catalog3d-room><p>Loading your room…</p></catalog3d-room> — the obvious thing to write — meant no iframe,
ever, with no error. The element now tracks its own mount state instead of
inferring it from childElementCount.
Mount attempts are serialized because a pending mount owns the target
registration until its promise settles. A real removal cancels that registered
pending handle immediately; if the element is added again later, the next mount
does not wait for the 20-second ready timeout. The first mount still runs
synchronously so the iframe exists the moment the element connects and the host
page's layout does not shift.
Strictness at the boundary
Unknown options are rejected. { local: "de" } used to mount silently in
English; { accentcolor: "#639" } silently dropped the accent. On a third-party
embed a silent misconfiguration is far worse than a loud one: the merchant sees
a working-but-wrong widget and has nothing to search for. INVALID_CONFIG at
integration time is easier to diagnose. The public message does not echo unknown
names or values, so the error cannot become an attacker-controlled reflection
channel.
Server rendering no longer throws. The custom element class extended
HTMLElement at module scope, so importing the loader in Node died with
ReferenceError: HTMLElement is not defined — a real path, since the documented
Next.js integration server-renders the surrounding page. The class definition is
now guarded, and mount() rejects with a public error instead of throwing when
there is no document.
The iframe title follows locale. It is the accessible name the host page's
screen reader announces, and leaving it English on a German or French store is a
defect in the store's accessibility, not just ours.
Deliberately unchanged
- The 20-second ready timeout is not configurable. Adding an option is
cheap; removing one after stores depend on it is not. If real-world telemetry
says it is too tight for 3D on mobile, changing the constant is a patch
release, and adding an option remains available later.
data-catalog3d-host still accepts any HTTPS origin. It is what makes
local development and the bundled example work, and it is set by the page
author, who already controls the page. It is now documented rather than
implicit, and the security page no longer claims the loader pins a constant
Catalog3D origin when what it actually pins is the origin its own script came
from.
FRAME_LOAD_FAILED is kept even though it rarely fires. Iframes fire
load, not error, for HTTP 4xx/5xx and for X-Frame-Options refusals, so
in practice a failed frame surfaces as TIMEOUT. The handler costs nothing
and covers the network-level cases that do fire; the docs now say which code
a merchant should actually expect.
- The version string stays hand-written in five places. Injecting it at
build time would fix two of them and leave the docs and changelog to drift. A
test asserts all five agree, which covers every copy.
dist/ stays committed. The repository doubles as the source for the
served tag. A test now asserts the committed build matches the current version
and that the published types match the source types, and CI fails if the build
is stale.