Bundling, CDNs and classic scripts
The package is ESM only. It has four entry points (@tinyactors/scxmljs, /trusted, /view,
/explorer) and a stylesheet (/themes/tinyactors.css). This page shows how to get them into a
page: with a bundler, from a CDN with an import map, or as classic <script> files.
With a bundler#
Vite, esbuild, webpack, Rollup, Parcel and Bun need no configuration. Import what you use:
import { createSession } from "@tinyactors/scxmljs"; // or "@tinyactors/scxmljs/trusted"
import "@tinyactors/scxmljs/view"; // registers <scxml-view>
import "@tinyactors/scxmljs/explorer"; // registers <scxml-explorer>Things to know:
- Lazy engines.
<scxml-view>loads its data model withimport()when it first needs one: QuickJS for sandboxed charts, the host engine fortrustedones. With code splitting (on by default in Vite, webpack and Rollup;splitting: truein esbuild and Bun), each engine becomes a separate chunk that's only downloaded when used. - The sandbox is one file. QuickJS is compiled into the JavaScript (the WebAssembly is embedded
as base64), so there's no
.wasmfile to copy, serve or configure. - Side effects. The element modules register custom elements when imported.
package.jsonlists them insideEffects, so tree shaking keeps them; everything else can be shaken. - Server-side rendering. The element modules can be imported where there's no DOM; they just don't register anything there.
Sizes, minified and gzipped, for a browser bundle of each entry point alone:
| Import | Size |
|---|---|
@tinyactors/scxmljs |
20 KB, plus 290 KB of QuickJS loaded on first use |
@tinyactors/scxmljs/trusted |
17 KB |
@tinyactors/scxmljs/view |
33 KB, plus the engine it loads |
@tinyactors/scxmljs/explorer |
26 KB |
From a CDN, with an import map#
Browsers can load the published files directly. An import map tells them where each bare specifier lives: the package's entry points and its two QuickJS dependencies (which are only fetched when a sandboxed session is created).
<script type="importmap">
{
"imports": {
"@tinyactors/scxmljs": "https://cdn.jsdelivr.net/npm/@tinyactors/scxmljs@0.1.0/dist/index.js",
"@tinyactors/scxmljs/trusted": "https://cdn.jsdelivr.net/npm/@tinyactors/scxmljs@0.1.0/dist/trusted.js",
"@tinyactors/scxmljs/view": "https://cdn.jsdelivr.net/npm/@tinyactors/scxmljs@0.1.0/dist/view.js",
"@tinyactors/scxmljs/explorer": "https://cdn.jsdelivr.net/npm/@tinyactors/scxmljs@0.1.0/dist/explorer.js",
"quickjs-emscripten-core": "https://cdn.jsdelivr.net/npm/quickjs-emscripten-core@0.32.0/dist/index.mjs",
"@jitl/quickjs-singlefile-browser-release-sync": "https://cdn.jsdelivr.net/npm/@jitl/quickjs-singlefile-browser-release-sync@0.32.0/dist/index.mjs",
"@jitl/quickjs-ffi-types": "https://cdn.jsdelivr.net/npm/@jitl/quickjs-ffi-types@0.32.0/dist/index.mjs"
}
}
</script>
<script type="module">import "@tinyactors/scxmljs/view";</script>
<scxml-view src="traffic-light.scxml"></scxml-view>- Pin exact versions, and use the dependency versions from the package's
package.json. - The package's own files import each other with relative paths, so only the entry points need entries.
- An import map is an inline script: under a Content-Security-Policy it needs a hash or nonce in
script-src(see CSP). To avoid that, host the files yourself. - CDNs that rewrite imports for you, such as
https://esm.sh/@tinyactors/scxmljs@0.1.0/view, work without the dependency entries.
Both CDNs were tried with the published 0.1.0 in Chrome: this import map against jsDelivr (a
sandboxed <scxml-view>, QuickJS fetched on demand), and esm.sh without an import map (a
trusted <scxml-view>). The sandboxed engine is about 290 KB on first load, so give it a moment.
Classic scripts (no modules)#
For pages that can't use modules, build IIFE bundles. Classic scripts have no import.meta, which
the sandbox and <scxml-view> use to find their base URL, so define it as the page's URL:
const results = await Promise.all(
["scxml-view.ts", "scxml-global.ts"].map((entry) =>
Bun.build({
entrypoints: [entry],
outdir: "public/vendor",
format: "iife",
target: "browser",
minify: true,
// classic scripts have no import.meta: give the code the page's URL instead
define: { "import.meta.url": "document.baseURI" },
}),
),
);
for (const result of results) {
if (!result.success) throw new AggregateError(result.logs, "build failed");
for (const output of result.outputs) console.log(output.path.slice(output.path.indexOf("public/")));
}public/vendor/scxml-view.js
public/vendor/scxml-global.jsThe two entry files: scxml-view.ts registers the element,
import "@tinyactors/scxmljs/view";and scxml-global.ts puts the interpreter on window.scxml and registers the explorer:
import * as scxml from "@tinyactors/scxmljs/trusted";
import "@tinyactors/scxmljs/explorer";
Object.assign(globalThis, { scxml });<script src="vendor/scxml-view.js"></script>
<script src="vendor/scxml-global.js"></script>
<scxml-view src="traffic-light.scxml"></scxml-view>
<scxml-explorer></scxml-explorer>
<script src="app.js"></script> <!-- scxml.createSession(…), explorer.attach(…) -->With esbuild, the same build is --format=iife --define:import.meta.url=document.baseURI.
An IIFE can't load chunks lazily, so scxml-view.js contains both engines (about 1.3 MB
minified, 340 KB gzipped). For a smaller page, use modules. <scxml-explorer> and the trusted
entry point don't use import.meta and can be bundled as-is.
Tested in CI in Chromium, Firefox and WebKit, under both policies from CSP: both
bundles (built from the published files with exactly this recipe), a sandboxed <scxml-view>,
and a trusted session in <scxml-explorer> (tests/browser/specs/csp.pw.ts).
Support#
| Environment | Status | Needs |
|---|---|---|
| Chrome, Edge (Chromium) | tested in CI: Chromium 153 (and Chrome 154 by hand) | Chromium 123 or later (from the features used; older versions aren't tested) |
| Firefox | tested in CI: Firefox 155 | Firefox 120 or later (from the features used) |
| Safari | tested in CI with WebKit 26.6 (Playwright's build, see below) | Safari 17.5 or later (from the features used) |
| Node | tested in CI: 26.10 (and 22.3 by hand) | 22.3 or later (process.getBuiltinModule), and a DOMParser such as happy-dom |
| Bun | tested in CI: 1.4 | a DOMParser such as happy-dom |
| Deno | tested in CI: 2.9 | --allow-read --allow-env --allow-sys, and a DOMParser such as happy-dom (npm:happy-dom) |
What "tested in CI" covers:
- Browsers (
tests/browser, Playwright): both elements with the three playground systems; zero-JS<scxml-view src>and inline source; the sandboxed engine (WebAssembly) and the trusted one (the iframe realm); the explorer's levels, tree keyboard navigation, stepping and sending; 390px and wide layouts, container queries, light and dark, reduced motion, right-to-left pages; the Content-Security-Policies and Trusted Types from CSP; an axe-core audit (no violations); and screenshot comparisons. The packed files are loaded through an import map, as a plain HTML page would. - Runtimes (
scripts/smoke-tarball.ts): the packed tarball, installed withnpm installinto an empty project, runs a chart through both entry points in Node, Bun and Deno, and a bundler builds all four entry points from it. - Frameworks: the framework examples are built and loaded in Chromium.
Safari: Playwright's WebKit is the engine Safari uses, built from the same sources, but not Safari itself (and on Linux in CI). Differences are rare; the package hasn't been tried in a shipping Safari by hand yet.
The browser minimums come from light-dark() in the default theme (the newest feature used),
constructable stylesheets, container queries and color-mix(). The sandbox needs WebAssembly;
the trusted entry point needs eval in an iframe. See CSP for both.