Compare commits

...
Author SHA1 Message Date
Timo K.andClaude Fable 5.1 3c8b381723 Fail the component build on what a host must not be handed
A library build inlines an asset as a data URL the moment its import
loses `no-inline`, and emits the standalone app's store worker the
moment a module the component reaches imports it; neither warns, and
the package grows by 11 MB without anyone seeing it in a diff. It had
been published at 18 MB for several releases before it was measured.

`scripts/check-component-bundle.mjs` now runs at the end of the
component build and fails it on a data URL over 16 kB in a module a
host bundles, an absolute `/assets/` reference, the store worker under
`assets/`, or a main module over 4 MB, each with a message saying what
to do. It passes only once both the assets and the store worker fixes
are in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:44:22 +01:00
3 changed files with 98 additions and 1 deletions
+5
View File
@@ -243,6 +243,11 @@ needs a certificate exception for `https://localhost:3001` as well (see the
note under [Backend](#backend)). It reads the same `public/config.json` as
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
`pnpm build:component` ends with `scripts/check-component-bundle.mjs`, which
fails the build on what a host must not be handed: an asset inlined into the
module as a data URL, a file referenced by an absolute path, the standalone
app's client store worker, or a module over its size budget.
The call lays itself out for the size of the element it is mounted in, not the
window: a host that shrinks the container to a corner of its page gets the
picture-in-picture layout, just as a host that shrank the whole iframe used to.
+1 -1
View File
@@ -22,7 +22,7 @@
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
"build:component": "pnpm build:component:js && pnpm build:component:types",
"build:component:js": "pnpm build:full --config vite-component.config.js",
"build:component:js": "pnpm build:full --config vite-component.config.js && node scripts/check-component-bundle.mjs",
"build:component:types": "tsc -p component/tsconfig.build.json",
"build:component:production": "pnpm build:component",
"build:component:development": "pnpm build:component:js --mode development && pnpm build:component:types",
+92
View File
@@ -0,0 +1,92 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* Checks the component build's output for what a host must not be handed.
*
* A library build inlines every asset it meets as a data URL unless the import
* says otherwise, and does so silently: one import without `no-inline` and the
* module grows by the asset, encoded a third larger, that every host then
* downloads and parses before the first call. MediaPipe's runtime alone is
* 11 MB. A reference to a file by an absolute path is just as silent and
* breaks at runtime instead, since a host serves the module from wherever it
* likes. And the standalone app's client store worker, which carries a copy of
* the Matrix SDK and its crypto, has no business in a package whose host
* supplies the client: the bundler emits it on loading its import, used or
* not (see src/utils/initClient.ts). This fails the build on each of those.
*/
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
const dist = "component/dist";
/** The longest data URL a chunk may carry: enough for an icon, not a sound. */
const inlineLimit = 16 * 1024;
/**
* The module itself, in bytes. Well above where it sits with its assets as
* files, so that only an asset finding its way back in trips it.
*/
const moduleBudget = 4 * 1024 * 1024;
/**
* The modules a host bundles: the entry points and their chunks, at the top of
* the package. What sits under `assets/` is fetched as it is, by URL.
*/
async function modules() {
const names = await readdir(dist);
return names.filter((name) => name.endsWith(".js")).map((n) => join(dist, n));
}
const problems = [];
for (const name of await readdir(join(dist, "assets")))
if (name.includes("IndexedDBWorker"))
problems.push(
`${dist}/assets/${name}: the standalone app's client store worker is ` +
`in the package. Something the component reaches imports the worker; ` +
`keep it on the standalone app's side (see src/utils/initClient.ts ` +
`and the \`loadClient\` prop of ClientProvider).`,
);
for (const file of await modules()) {
const code = await readFile(file, "utf8");
for (const [, type, payload] of code.matchAll(
/data:([a-z0-9/.+-]*);base64,([A-Za-z0-9+/=]*)/g,
))
if (payload.length > inlineLimit)
problems.push(
`${file}: a ${type} asset of ${Math.round(payload.length / 1024)} kB ` +
`is inlined as a data URL. Import it with \`?url&no-inline\` so ` +
`that it is emitted as a file.`,
);
for (const [reference] of code.matchAll(/["'`]\/assets\/[^"'`]+["'`]/g))
problems.push(
`${file}: ${reference} is an absolute path, which only resolves ` +
`against an origin that serves the package at its root. The build ` +
`is meant to render relative references; check \`base\` in ` +
`vite-component.config.ts.`,
);
}
const moduleSize = (await stat(join(dist, "element-call.js"))).size;
if (moduleSize > moduleBudget)
problems.push(
`${dist}/element-call.js is ${Math.round(moduleSize / 1024 / 1024)} MB, ` +
`over its ${moduleBudget / 1024 / 1024} MB budget. Something large ` +
`has been bundled into the module that a host would rather fetch when ` +
`it is needed.`,
);
if (problems.length > 0) {
console.error(`The component build is not fit to publish:\n`);
for (const problem of problems) console.error(` ${problem}\n`);
process.exit(1);
}