← Back to Learn

How to build a JavaScript SDK: 5 real teardowns

Card for the series "How to build a JavaScript SDK — five real teardowns", over a photo of components laid out on a board

If you ship a video player, camera feed or chat widget that customers paste into their site with one script tag, the first decision is the embedding shape. Render inside your own iframe if you must stay clear of the customer's CSS and CSP or the data is sensitive, and keep the version to yourself. Inject into their DOM if customers build their own UI, and let them pin an exact version. The bundles of YouTube, Safie, Vimeo, Mux Player and Video.js, fetched on 2026-08-08, laid out against six decisions: shape, loader/body split, CSP, postMessage, versioning and initialization. Sizes and headers are measured; behavior comes from reading the shipped JavaScript; the skeleton code in the final part has not been run.

What you can do after reading

  • Pick the embedding shape first: an iframe that keeps the version to yourself if the customer's CSS and CSP must not interfere or the data is sensitive, and DOM injection with a URL that pins an exact version if customers build their own UI
  • Carry seven patterns from YouTube's 993-byte loader into your own, including the double-load guard and currentScript.nonce propagation
  • Lock down the inside of your iframe the way Safie does, with a per-request nonce CSP and a narrow connect-src
  • Make postMessage a contract that stays unambiguous even with two embeds on one page, by matching the origin exactly and checking event.source
  • Read the skeleton of a minimal embeddable SDK built by making the six decisions one at a time, and adapt it to your own requirements

Some products have to run inside someone else’s website: video players, camera feeds, chat widgets. Ship them as an npm package and only customers with engineers and a build step can install them. Ship a CDN URL plus one <script> tag and they run anywhere HTML can be pasted, CMS installs included. Where near-zero setup is the condition for being adopted at all, this is the only way to ship.

Behind that one line sit six decisions: the embedding shape, the loader/body split, CSP, the postMessage contract, versioning and caching, and the initialization entry point. And the code you ship runs inside the customer’s page with the same privileges as their own scripts.

If your code must stay clear of the customer’s CSS and CSP, or the data it handles would hurt to leak, render inside your own iframe and keep the version to yourself. If customers build their own UI or want your SDK to blend into their framework, inject into their DOM and let them choose a URL that pins an exact version. Once the embedding shape is chosen, the versioning policy and the need for postMessage mostly follow. Across the five products here, the iframe SDKs (YouTube, Safie, Vimeo) all refuse to let customers pin a version, and both DOM SDKs let customers choose a version without using postMessage: Video.js requires a version in the URL, and Mux Player caches a pinned URL immutable for a year.

One pasted script, two places to render: what the JS behind the pasted URL does on the customer's page, for all five products. The iframe SDKs: Safie opens the same iframe.html four times and sets each role with the hash, rendering in an iframe on safie.link where the whole UI lives. YouTube injects its body into the customer's page, then builds an iframe at the placeholder and renders in an iframe on youtube.com. Vimeo attaches to an iframe the customer wrote, or builds one inside an element, and renders in Vimeo's own iframe. These three render on the vendor's own origin, where the customer's CSS and JS can't reach. The DOM SDKs: Mux Player defines mux-player as a custom element, and Video.js turns a video element the customer wrote into a player. These two render in the customer's DOM. Bundles fetched 2026-08-08 (parts of Safie and Vimeo re-checked on 09-30)

The other decisions don’t split as cleanly. Whether to split the loader from the body depends on whether you want to swap the body on your own schedule. The initialization entry point should match whether your customers write static HTML or a single-page app. How heavy to make the CSP and the postMessage protocol depends, as I read it, on how sensitive the data is and how much state parent and child share. Double-load guards are the exception: four of the five ship one, so treat it as a default rather than a choice.

This series fetches the five bundles and reads them, one decision per part. Teardown first, abstraction second. The last part makes the six decisions again and builds a minimal embeddable SDK.

The common task

A comparison only means something when all five products do the same job.

  • The job: the customer pastes one <script> tag, and a UI that plays video appears on their page (a camera feed for Safie, video for the other four)
  • Starting point: the URL the customer pastes (the loader), followed through to the body it pulls in
  • Fetching: plain curl on 2026-08-08, no auth. Sizes are the HTTP body; gzip is what actually crosses the wire
  • Source of truth: the shipped JavaScript and the response headers, not the public docs
  • Re-checks: Safie’s key exchange, Vimeo’s wait-for-ready step and the double-load guards were re-fetched and re-read on 2026-09-30

Same job, same method, only the data's sensitivity differs: the comparison conditions laid out across the five products. Four rows are the same for all five: the job (one script tag puts a video-playing UI on the page; a camera feed for Safie, video for the other four), the starting point (the URL the customer pastes, the loader, followed through to the body), fetching (plain curl on 2026-08-08, no auth, parts re-fetched on 09-30) and what was read (shipped JS and response headers; public docs are not used as evidence). Only the data handled is not held constant: access to recorded footage for Safie, public video for YouTube and Vimeo, and not compared in the text for Mux Player and Video.js. As I read it, this gap shows up in the CSP and postMessage columns of the matrix (an inference)

Keeping the subject to video means a difference shows up as a design difference rather than a difference in what’s being embedded. The one thing that isn’t held constant is how sensitive the data is. Safie carries access to recorded camera footage; YouTube serves public videos that anyone can watch. As I read it, that gap is what shows up in the CSP and postMessage columns below.

This is not a ranking of which SDK is best. Each is tuned to different constraints, and the interesting part is how constraints map to decisions. I read bundles rather than docs because docs describe the API surface, while the bundle shows what one HTTP request actually delivers, and the two sometimes disagree.

What runs on the customer's page and what runs in the iframe, as bars proportional to size (HTTP body, measured with curl on 2026-08-08). iframe SDKs: Safie runs a 160 KB loader on the customer's page and a 2.61 MB body in the iframe. YouTube runs a 993 B loader and a 26.8 KB body on the customer's page; its iframe contents were not measured. Vimeo runs the 24.4 KB player.js on the customer's page; its iframe contents were not measured. DOM SDKs: Mux Player at 1.06 MB and Video.js at 690 KB are single files that run entirely on the customer's page. YouTube's loader injects the body into the customer's page, so the body counts on that side

The five products

ProductWhat it embedsURL the customer pastesIn one line
Safiecamera feedsafie.link/sdk/js/api/v1/latest/a 160 KB loader that isolates a 2.61 MB body in an iframe
YouTubevideoyoutube.com/iframe_apia 993-byte loader that injects a body with a build ID in its URL
Vimeovideoplayer.vimeo.com/api/player.jsiframe-based but one file; attaches to an iframe the customer writes, or builds one inside an element
Mux Playervideocdn.jsdelivr.net/npm/@mux/mux-playera batteries-included Web Component in the customer’s DOM
Video.jsvideovjs.zencdn.net/8.23.4/video.min.jsa library that turns the customer’s <video> element into a player

The first three are iframe-based; the last two inject into the DOM.

Five products, six decisions

Matrix of 5 products by 6 decisions. Rows are Safie, YouTube and Vimeo (iframe) and Mux Player and Video.js (DOM injection); columns are shape, loader and body, CSP, postMessage generation, version and initialization. The two yellow columns, postMessage and version, split exactly along the shape: the three iframe products are generations 1 to 3 and don't let customers choose a version, and the two DOM products don't use postMessage and let customers choose a version (Video.js requires an exact one). Vimeo's CSP was not checked

ProductShapeLoader and bodyCSPpostMessage generationVersionInitialization
Safieiframe (4 frames, role set by hash)split, 160 KB → 2.61 MBper-request nonce inside the iframe, connect-src limited to Safie’s own domains3, encrypted (syn → ack → connected, parent → child in AES-GCM)not pinnable (/latest/ with no-cache)imperative (namespace); no double-load guard found
YouTubeiframesplit, 993 B → 26.8 KBthe loader adapts to the host page’s CSP via Trusted Types and nonce propagation2, state sync (listening → initialDelivery, exact origin match)not pinnable (loader max-age=0, body URL carries a build ID)imperative (callback); window.YT guard
Vimeoiframe (attached to the customer’s iframe, or built by the SDK inside an element)single file, 24.4 KBnot checked1, minimal (ping → ready, first origin pinned and event.source checked)not pinnable (unversioned URL, max-age=1800)imperative (constructor); marks initialized elements
Mux PlayerDOM (Web Components)single file, 1.06 MBruns under the customer’s CSP (by construction; not checked separately)nonepinnable (@3.7.0 is cached immutable for a year)Web Components; customElements.get guard
Video.jsDOM (<video> element)single file, 690 KBruns under the customer’s CSP (by construction; not checked separately)noneexact version required (/8/ returns 403)data- attributes; skips elements already set up

The Version and postMessage columns split along exactly the same line as the Shape column. None of the three iframe SDKs lets customers pin a version, and neither DOM SDK uses postMessage, while both let customers choose a version.

The bundles don’t say why the versioning policy follows the shape so closely. What follows is my reading. An iframe SDK has a parent (the customer’s page) and a child (your own-origin frame) that talk over postMessage, so the protocol version has to stay in step on both sides, and a customer who leaves an old loader pasted forever would break that. A DOM SDK has no parent-child conversation; instead, API changes break the customer’s own code, so both products settled on letting customers decide when to upgrade.

The further split in CSP and postMessage within the iframe group comes, as I read it, from how sensitive the data is. YouTube and Vimeo, serving public video, don’t encrypt anything. Safie, which handles access to recorded footage, sets a per-request nonce CSP inside its frame and encrypts parent-to-child commands with AES-GCM. Video.js is a library, so it leaves security to the customer’s side, and Mux enters the customer’s DOM as a batteries-included Web Component and gives up isolation in exchange.

What each column measures

The six columns in the table are the yardsticks.

DecisionWhat it looks atCovered in
ShapeRender inside your own-origin iframe, or inject into the customer’s DOM. Isolation costs you: every call has to go through postMessageParts 2 and 4
Loader and bodyWhether the URL the customer pastes (the loader) is separate from the code that does the work (the body). Splitting lets you swap the body without changing the customer’s URLPart 1
CSPWhether you live under the host page’s CSP or set your own inside a frame. Setting your own lets you limit where a compromised script could send dataParts 1 and 2
postMessage generationHow heavy the parent-child protocol is: 1 minimal (wait for ready), 2 state sync (push initial state), 3 encryptedPart 3
VersionWhether customers choose a version or you switch it for them. The loader’s and body’s cache-control headers express the policy directlyPart 4
InitializationThe entry point customers call (imperative, data- attributes or Web Components), and what happens when the SDK is loaded twicePart 5

Two of the six set the shape

Design axes of a one-line script SDK. Five products placed on a four-quadrant map of iframe-isolated vs injected into the DOM against split loader vs single file. Safie and YouTube top-left, Vimeo bottom-left, Mux Player and Video.js bottom-right, and the DOM-plus-split quadrant empty

Of the six, the embedding shape and the loader split decide what the SDK looks like from the outside. Going iframe makes splitting tempting (the customer’s page needs a postMessage shim, the frame needs the whole UI), though Vimeo is the exception in this sample: iframe-based, yet player.js is a single file. The DOM-plus-split quadrant is empty. My reading is that for a DOM SDK, a split does little to keep the customer’s URL fixed while swapping the body, and leaves only the cost of an extra request.

What the shape changes becomes clear when you list what happens on the customer’s page.

On the customer’s pageInjected into the DOMIsolated in an iframe
CSStheir stylesheet overrides yoursscoped to the frame
JStheir code can rewrite yoursseparated by the same-origin policy
Cookiesyou share their jarbound to your own origin
CSPyou live under theirsyou set your own inside the frame
What it costsnothingone extra request, and postMessage RPC for every call

The moment the customer writes <script src="…">, your script has XSS-equivalent power on their page. An iframe keeps whatever runs inside it away from the parent’s DOM, cookies and forms. It does not stop requests leaving the frame, and if you skip event.origin checks on postMessage, an attacker’s frame can send you commands. Choose isolation but get careless at the protocol layer, and the same hole opens up again. That is the core of Parts 2 and 3.

What an iframe stops and what it doesn't, on a customer page (example.com) running an iframe SDK. Arrows from the customer's CSS, JS and cookies to the own-origin iframe (child) are blocked at the same-origin policy wall, and the iframe can't touch the parent's DOM, cookies or forms either. Inside the iframe the whole UI runs, CSS stays inside the frame, and you set your own CSP. The cost is that every call from the loader (parent) goes through postMessage. Two things are not stopped: requests leaving the frame for an external server, and commands sent from an attacker's iframe when you skip the event.origin check. Rendering also costs one extra request, and with DOM injection all three of the customer's CSS, JS and cookies get through

Which one fits your situation

This is the same table as at the end of the final part, Build your own embed SDK: a chat widget in 6 decisions.

SituationWhat to chooseWhy it works
The customer site’s CSS, JS and cookies must not interfere with yoursAn iframe. Split the loader from the body, and don’t let customers choose a versionThe customer’s styles and window.fetch rewrites can’t reach you. A loader refetched every time keeps parent and child on the same version
Customers build their own UI or want it to blend into their frameworkDOM injection (Web Components). Let customers choose a URL that can name an exact versionOnce customers write a version, they choose when to upgrade even if the API changes (Video.js requires a version; Mux Player, if you write one, is cached immutable for a year)
You handle things that would hurt to leak, such as auth tokens or recorded footageSet a per-request nonce CSP inside the iframe and lock connect-src to your own hostsEven a hijacked frame can’t carry data out (Safie). Static hosting alone can’t build it
Parent and child share stateWait for the child’s ready signal, then push the initial stateYouTube’s listening → initialDelivery
You may be embedded two or more times on the same pageCheck event.source in addition to an exact origin matchBoth iframes have the same origin, so origin alone can’t tell whether it’s your iframe
Customers use static HTML or a CMS and write no JSInitialize from data attributesLike Video.js’s data-setup, it works just by pasting
Customers are split between static HTML and single-page appsInitialize imperatively, with a deferred command queueCalls made before load are queued in an array and replayed after load, so the same code works in both
Any shapeAdd a double-load guard from the first commitTag managers fire snippets twice. Four of the five products ship one

Once the top two rows settle the shape, the versioning policy is settled with it. The remaining rows are about what to add inside the shape you chose, and the deciding factors are how sensitive the data is and how your customers write code.

Pick the shape and the versioning follows, then add the rest by situation: the "Which one fits your situation" table reordered by when you decide, with the same conclusion as the final part. If the customer's CSS, JS and cookies must not reach you, use an iframe: split the loader and body, and customers can't pick a version. Inside that shape, add a per-request nonce CSP inside the iframe with connect-src locked to your own hosts if you handle data that would hurt to leak; wait for the child's ready signal, then push the initial state if parent and child share state; and check event.source as well as an exact origin match if you may be embedded twice or more on one page. If customers build the UI or blend it into a framework, use DOM injection (Web Components) and let customers pick a URL that can name an exact version; with no iframe and no parent-child conversation, the three iframe rows don't apply (neither DOM SDK here uses postMessage). For either shape: initialize from data attributes for static HTML or a CMS with no JS, initialize imperatively with a deferred command queue when customers use both static HTML and SPAs, and add a double-load guard from the first commit because tag managers can fire twice. Four of the five products ship a double-load guard

Verification status

Sizes and headers are measured; behavior comes from reading the shipped JavaScript. The code in the final part has not been run.

PartMeasured (sizes and headers fetched with curl)Read from source (behavior found by grepping and reading the shipped JS)Not run
This pageloader and body sizes for all five (including Vimeo 24.4 KB, Mux Player 1.06 MB and Video.js 690 KB, measured with curl on 2026-08-08)the matrix cells for CSP, postMessage and initialization (details in Parts 2 to 5)―
1 Loader and bodyYouTube’s loader at 993 B / 572 B gzip, the 26.8 KB body, their cache-control headersthe seven loader patterns (Trusted Types, double-load guard, currentScript.nonce propagation and others)―
2 Iframe isolationSafie’s 160 KB loader, the breakdown of the 2.61 MB body, the CSP header on iframe.htmlhow the four iframes split roles, how the SYN/ACK and AES-GCM keys are handed over (re-fetched and confirmed on 2026-09-30)―
3 postMessage―the three products’ message formats, origin checks and handshakes (Vimeo’s wait-for-ready step re-fetched and confirmed on 2026-09-30)―
4 Versions and cache-controlcache-control for all five, Video.js /8/ returning 403――
5 Initialization―the three initialization styles, double-load guards in four of the five products (re-fetched and confirmed on 2026-09-30)―
6 Build your own――the skeleton loader and iframe body; not run in a browser

Two inferences sit outside the table: why iframe SDKs refuse version pinning (to keep the parent-child protocol in step), and why the weight of CSP and postMessage splits by sensitivity (Safie handles access to recorded footage). Neither is written in the bundles, so the text flags them, along with the reason for the empty quadrant, as “my reading”. Vimeo’s CSP, and how Mux Player and Video.js behave under a customer’s CSP, were not examined in this series.

Where measurement ends and inference begins: the claims in this series in five levels, most solid at the top. Measured (values fetched with curl on 2026-08-08): loader and body sizes for all five, cache-control headers, the CSP header on Safie's iframe.html, and Video.js /8/ returning 403. Read from source (behavior in the shipped JS): YouTube's seven loader patterns, Safie's four iframes and key exchange, three products' postMessage, the three init styles and the double-load guards, with Safie's key exchange, Vimeo's wait-for-ready and the guards re-fetched on 09-30. My inference (reasons not in the bundles): why iframe SDKs keep the version to themselves, why CSP and postMessage weight splits by sensitivity, and why the DOM-plus-split quadrant is empty, flagged in the text with "as I read it" or "my reading". Not read (outside this series): Vimeo's CSP and how Mux Player and Video.js behave under the customer's CSP, marked "not checked" and "by construction; not checked separately" in the matrix. Not run (written only): the skeleton loader and iframe body in the final part, not run in a browser

Reading order

Six parts follow this page. Read in order for the argument; jump to a part when you have one specific question.

One question? Start at that part. Parts 1 to 5 take the five products apart and Part 6 makes the six decisions again. Part 1 asks what goes in the loader, takes apart YouTube, and covers the loader split and CSP. Part 2 asks how to lock down the iframe, takes apart Safie, and covers shape and CSP. Part 3 asks how heavy the protocol should be, takes apart Vimeo, YouTube and Safie, and covers postMessage. Part 4 asks whether customers should pick a version, takes apart all five, and covers version and shape. Part 5 asks how customers call the SDK, takes apart all five, and covers initialization. All five parts feed into Part 6, Build your own, which uses a chat widget to make the six decisions again one at a time and write the skeleton of a minimal embeddable SDK. The skeleton code has not been run in a browser

Measurements as of 2026-08-08. Build IDs and exact byte counts will drift; the shape of the design space will not.