Summary#
A control is one bundle, and every page that mounts it downloads all of it. That is the right shape for a control's own code, and the wrong shape for a feature most documents never touch: a maths renderer is a quarter of a megabyte, a diagram engine several times that, and bundling either means charging every reader for a thing one page in fifty uses.
A chunks { } block is the second address. It names the assets a control ships separately, and the shim asks for
one by name at the moment it needs it:
if (documentHasMath) {
const { render } = await import(host.chunkUrl("Math"));
render(el, source);
}A chunk is stored, content-addressed, cached and garbage-collected exactly like the bundle beside it. What is different is only that the platform does not load it — the control decides when, and whether at all.
A chunk can be a single file or a whole package directory. The package shape exists for libraries that load their
own parts at run time — a worker, a .wasm sibling, a renderer per diagram type — which no bundler can flatten into
one module. See One file, or a package below.
Signature#
control <Name> {
chunks {
<ChunkName>;
<ChunkName>;
}
}Registered per name — as one file, or as a directory:
osy control add controls/editor.js \
--chunk Math=vendor/temml.js \
--chunk MathCss=vendor/temml.css \
--chunk-package Diagrams=dist/diagrams/index.jsDescription#
Declaring a chunk and pointing it at a file#
The declaration names the chunks; the registration says which file each one is.
control MarkdownEditor {
contractVersion "1.1"
participation headless
props { string ownerType; }
chunks {
/// A MathML renderer, loaded the first time a document contains `$…$`.
Math;
/// Its stylesheet.
MathCss;
}
}
[Page("/doc")] [AllowAnonymous]
component DocPage() {
render {
MarkdownEditor(ownerType: "Document");
}
}The two halves are checked against each other. A --chunk name the control does not declare is refused, naming the
ones it does; a declared chunk with no file registered is a compile error once anything renders the control, and a
warning while nothing does — so the bundle can be registered first and its chunks next, without a broken state in
between.
Nothing about a chunk reaches the app. A chunks { } block changes no call site, no prop, and no page: it is a
statement about how the control ships itself.
Getting a chunk's URL in the shim — host.chunkUrl#
host.chunkUrl(name) returns a same-origin URL. The generated typings key it to the names you declared, so a typo does
not compile:
export interface MarkdownEditorHost {
chunkUrl(name: "Math" | "MathCss"): string;
}It returns a URL, not a module, and that is deliberate. One member then covers both kinds of asset — a module you
import(), and a stylesheet you put in a <link>:
// a module
const temml = await import(host.chunkUrl("Math"));
// a stylesheet
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = host.chunkUrl("MathCss");
document.head.appendChild(link);A member that returned a module could not express the stylesheet at all, and one that decided when to load would be answering a question only the control knows the answer to.
The URL is content-addressed and immutable, so caching it forever is safe and re-asking is free — chunkUrl does no
work beyond a lookup. Loading twice is the shim's own concern: keep the promise.
One file, or a package#
A chunk name resolves to one of two things, and the registration decides which. The declaration is the same either
way — a chunk name is a name, and host.chunkUrl() returns something you can load.
A single file is served at its own content address, with no directory around it. So a relative specifier inside one has nothing to resolve against:
import { helper } from "./helper.js"; // ✗ refused in a single-file chunk — there is no "." to be relative toosy control add refuses that at registration and names the specifier it found. Bundle the chunk to one file
(esbuild --bundle --format=esm), or register it as a package instead.
The same applies to a stylesheet's url() in a single-file chunk. Its CSS cannot reference a font by relative path;
register the face with web fonts — shipping a typeface with your app and point the url() at the address that returns.
A package is a whole directory, served under a real base — so relative imports resolve exactly as they do on a static host:
osy control add controls/editor.js --chunk-package Diagrams=dist/diagrams/index.jsYou give the entry file; the package is the directory containing it, taken recursively. host.chunkUrl("Diagrams")
returns that entry's URL, and everything the entry reaches from there — ./chunks/x.js, a .wasm sibling, a worker
started with new Worker(new URL("./worker.js", import.meta.url)) — resolves against the directory it genuinely sits
in.
Reach for a package when a library loads its own parts at run time. Many do, and flattening one into a single file
is both wasteful and sometimes impossible: a diagram engine that lazy-loads a renderer per diagram type becomes one
module carrying every renderer, and a library that starts a worker or fetches a .wasm cannot be bundled at all.
⚠️ Register your own build output, never a package's distribution directory. A published package typically ships several build flavours and a full type-definition tree — one common diagram library's is 83 MB across 1167 files. Point a bundler at the library and register what it writes:
esbuild node_modules/some-lib/dist/index.mjs --bundle --format=esm --minify --splitting --outdir=dist/diagrams
osy control add controls/editor.js --chunk-package Diagrams=dist/diagrams/index.jsA path inside node_modules is refused by name, before anything is read.
A package's identity is its whole file set, so re-registering an unchanged directory writes nothing, and a file added or deleted since it was registered fails the next compile rather than shipping a set nobody registered.
What may be served#
A chunk's content type comes from its extension, and the set is closed:
| Extension | Served as |
|---|---|
.js, .mjs | text/javascript |
.css | text/css |
.json | application/json |
.wasm | application/wasm |
.woff2, .woff, .ttf | the matching font type |
Anything else is refused. .html and .svg are refused deliberately: both would give the app a same-origin address
that executes script, and no control needs one.
Anything else is refused, in a package member exactly as in a single file — and it is refused by name rather than skipped, so a build output containing (say) a source map tells you which file to exclude instead of registering cleanly and 404ing later on the one file nobody thought about.
The per-asset size limit is the bundle's — 5 MB, applied per file — and every asset a control ships counts toward the app's total.
A chunk is not a prop, a style, or a slot#
Four ways a control takes something from outside, and they answer different questions:
| What it is | Decided by | |
|---|---|---|
| prop | a value the control renders with | the app, per call site |
| style | a look knob the app may override | the app, or its theme |
| slot | content the app writes inside the control | the app |
| chunk | code or an asset the control ships itself | the control |
A chunk is the only one of the four the app knows nothing about. If an app should be able to decide something, that is a prop; if it should be able to supply something, that is a slot. A chunk is the control's own weight, moved off the critical path.
Unlike styles { }, a chunk is legal at every participation rung — including opaque. A chunk is neither look nor
app data; it is the control's own code, and an opaque island is exactly the kind of control that ships a heavy renderer.
Examples#
A diagram control that loads its engine once, on the first diagram it meets:
control DiagramView {
contractVersion "1.1"
participation opaque
props { string source; }
chunks { Engine; }
}let engine: Promise<{ render(el: HTMLElement, src: string): void }> | undefined;
export function mount(el, props, host) {
const draw = async (src: string) => {
// Loaded ONCE and remembered — a second diagram on the page costs nothing.
engine ??= import(host.chunkUrl("Engine"));
(await engine).render(el, src);
};
if (props.source) void draw(props.source);
return {
update(next) { if (next.source) void draw(next.source); },
destroy() { el.replaceChildren(); },
};
}The page that renders DiagramView with no source downloads the control and nothing else.
See also#
- control — foreign UI controls (charts, grids, maps) — the
controlblock chunks are declared in - commands — the verbs a control accepts — the verbs a control publishes
- styles — a control's own look knobs — a control's own look knobs
- web fonts — shipping a typeface with your app — how an app ships a typeface, including one a chunk's stylesheet names