mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-01 04:38:11 +00:00
149 lines
5.4 KiB
JavaScript
149 lines
5.4 KiB
JavaScript
/*
|
|
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 that the component build leaves the packages a host must supply to
|
|
* the host.
|
|
*
|
|
* A host application already has React, the Matrix SDK, LiveKit and Compound,
|
|
* and a second copy of any of them is worse than dead weight: React would hold
|
|
* two sets of hooks, the Matrix client would run two sync loops, and a second
|
|
* Compound would style the tooltips it floats into the host's body with class
|
|
* names the host's stylesheet does not know. So the
|
|
* component build lists them as external — but that list has to name every
|
|
* subpath, since the bundler silently ignores the pattern and callback forms
|
|
* of the option, and an import it does not cover is bundled with no warning at
|
|
* all. That is the failure this guards against.
|
|
*
|
|
* It reads the list from the build config itself, so there is one copy of it,
|
|
* and compares it against every import of those packages in the source.
|
|
*
|
|
* The comparison is deliberately over-approximate: it looks at all of `src`
|
|
* rather than only the modules the component actually pulls in, so it will
|
|
* sometimes ask for a subpath that only the standalone app imports. Listing
|
|
* one the component never imports costs nothing — the bundler ignores it —
|
|
* whereas missing one costs a duplicate package.
|
|
*/
|
|
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { loadConfigFromFile } from "vite";
|
|
|
|
const CONFIG = "vite-component.config.ts";
|
|
const SOURCES = ["src", "component"];
|
|
// The development harness is a host, not part of the component: it is the
|
|
// one that imports what a host supplies (Compound's stylesheets, say).
|
|
const EXCLUDED = ["component/dev"];
|
|
|
|
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
|
const MUST_BE_EXTERNAL = [
|
|
"react",
|
|
"react-dom",
|
|
"matrix-js-sdk",
|
|
"livekit-client",
|
|
"@vector-im/compound-web",
|
|
"@vector-im/compound-design-tokens",
|
|
];
|
|
|
|
const isTestFile = (name) =>
|
|
name.includes(".test.") || name.includes(".stories.");
|
|
|
|
/** Every source file under the given directories, recursively. */
|
|
async function* sourceFiles(dir) {
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (EXCLUDED.includes(path)) continue;
|
|
if (entry.isDirectory()) yield* sourceFiles(path);
|
|
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
|
yield path;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The module specifiers a source file imports. Covers `from "…"` (which is
|
|
* both static imports and re-exports), bare `import "…"` for side effects, and
|
|
* dynamic `import("…")`.
|
|
*/
|
|
function imports(source) {
|
|
const specifiers = [];
|
|
for (const pattern of [
|
|
/\bfrom\s*["']([^"']+)["']/g,
|
|
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
/^\s*import\s+["']([^"']+)["']/gm,
|
|
])
|
|
for (const [, specifier] of source.matchAll(pattern))
|
|
specifiers.push(specifier);
|
|
return specifiers;
|
|
}
|
|
|
|
/**
|
|
* Whether a specifier is an import of one of the packages we care about.
|
|
*
|
|
* Imports carrying a resource query — `?worker`, `?inline` and friends — are
|
|
* not, whatever package they name. Those ask the bundler for a script to run
|
|
* in a context of its own, which has to be self-contained and shares no state
|
|
* with the host's copy of anything. Worker sub-builds do not inherit this
|
|
* option anyway.
|
|
*/
|
|
const mustBeExternal = (specifier) =>
|
|
!specifier.includes("?") &&
|
|
MUST_BE_EXTERNAL.some(
|
|
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
|
);
|
|
|
|
const loaded = await loadConfigFromFile(
|
|
{ command: "build", mode: "production" },
|
|
CONFIG,
|
|
);
|
|
if (loaded === null) {
|
|
console.error(`Could not load ${CONFIG}`);
|
|
process.exit(1);
|
|
}
|
|
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
|
if (declared.size === 0) {
|
|
console.error(
|
|
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
|
`list is empty; either way this check is not looking at what it thinks.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Where each missing specifier is imported, so the message can point at it
|
|
const missing = new Map();
|
|
const seen = new Set();
|
|
for (const dir of SOURCES)
|
|
for await (const file of sourceFiles(dir)) {
|
|
const source = await readFile(file, "utf8");
|
|
for (const specifier of imports(source)) {
|
|
if (!mustBeExternal(specifier)) continue;
|
|
seen.add(specifier);
|
|
if (declared.has(specifier)) continue;
|
|
const files = missing.get(specifier) ?? [];
|
|
files.push(file);
|
|
missing.set(specifier, files);
|
|
}
|
|
}
|
|
|
|
if (missing.size > 0) {
|
|
console.error(
|
|
`${CONFIG} does not declare these imports external, so the component ` +
|
|
`build would bundle its own copy of them:\n`,
|
|
);
|
|
for (const [specifier, files] of [...missing].sort())
|
|
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
|
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Deliberately no complaint about declarations nothing imports. Some of them
|
|
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
|
// the JSX transform — and an extra declaration is inert, so there is nothing
|
|
// to warn about.
|
|
console.log(
|
|
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
|
);
|