mtp/src/vite/index.ts

529 lines
15 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
import {
FIRST_USER_TYPE_ID,
RESERVED_COMMUNICATION_TYPES,
RESERVED_DATA_TYPES,
} from "../type-map/reserved.js";
export interface MTPVitePluginOptions {
typeMaps: string;
release?: boolean;
wasmPackArgs?: string[];
outDir?: string;
}
export interface VitePlugin {
name: string;
config?: (...args: any[]) => unknown;
buildStart?: (...args: any[]) => unknown;
configureServer?: (...args: any[]) => unknown;
addWatchFile?: (file: string) => void;
}
const packageRoot = process.env.MTP_PACKAGE_ROOT
? path.resolve(process.env.MTP_PACKAGE_ROOT)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const rawEntryName = "mtp_wasm.js";
const wasmEntryName = "mtp_wasm_bg.wasm";
const typeMapEntryName = "mtp_type_map.js";
function normalizeOptions(options) {
if (!options?.typeMaps) {
throw new Error(
"mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })",
);
}
return options;
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function devServerPath(root: string, filePath: string) {
const relativePath = path.relative(root, filePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return null;
}
return `/${relativePath.split(path.sep).join("/")}`;
}
export async function hashPackageInputs(root = packageRoot) {
const hash = crypto.createHash("sha256");
const inputs = [
"wasm/Cargo.toml",
"wasm/src",
"common/Cargo.toml",
"common/src",
"codec/Cargo.toml",
"codec/src",
"crypto/Cargo.toml",
"crypto/src",
"type-map/Cargo.toml",
"type-map/build.rs",
"type-map/reserved.json",
"type-map/src",
];
async function addPath(relativePath) {
const absolutePath = path.join(root, relativePath);
const stat = await fs.stat(absolutePath).catch(() => null);
if (!stat) {
return;
}
if (stat.isDirectory()) {
const entries = await fs.readdir(absolutePath);
for (const entry of entries.sort()) {
await addPath(path.join(relativePath, entry));
}
return;
}
hash.update(relativePath);
hash.update(await fs.readFile(absolutePath));
}
for (const input of inputs) {
await addPath(input);
}
return hash.digest("hex");
}
function quoteList(values: string[]): string {
return values.length === 0
? "never"
: values.map((value) => JSON.stringify(value)).join(" | ");
}
export function parseTypeMapYaml(source: string, filePath: string) {
const document = YAML.parseDocument(source, { prettyErrors: false });
if (document.errors.length) {
const error = document.errors[0];
const line =
error.pos?.[0] === undefined
? 1
: source.slice(0, error.pos[0]).split("\n").length;
throw new Error(`${filePath}:${line}: ${error.message}`);
}
const root = document.toJS() as {
protocol_version?: unknown;
type_maps?: unknown;
};
if (
root === null ||
typeof root !== "object" ||
Array.isArray(root) ||
typeof root.protocol_version !== "string" ||
!/^\d+\.\d+$/.test(root.protocol_version)
) {
throw new Error(
`${filePath}: protocol_version must be a string matching '<major>.<minor>'`,
);
}
if (
root.type_maps === null ||
typeof root.type_maps !== "object" ||
Array.isArray(root.type_maps)
) {
throw new Error(`${filePath}: type_maps must be a mapping`);
}
const typeMaps = root.type_maps as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(typeMaps, root.protocol_version)) {
throw new Error(
`${filePath}: protocol_version '${root.protocol_version}' is not defined in type_maps`,
);
}
const reservedCommunicationTypes = new Set(RESERVED_COMMUNICATION_TYPES);
const reservedDataTypes = new Set(RESERVED_DATA_TYPES);
const communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
const dataTypes = new Set<string>(RESERVED_DATA_TYPES);
for (const [version, rawMap] of Object.entries(typeMaps)) {
if (!/^\d+\.\d+$/.test(version))
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
if (
rawMap === null ||
typeof rawMap !== "object" ||
Array.isArray(rawMap)
) {
throw new Error(`${filePath}: ${version} must be a mapping`);
}
const map = rawMap as {
CommunicationTypes?: unknown;
DataTypes?: unknown;
};
for (const { section, reserved, selected } of [
{
section: "CommunicationTypes",
reserved: reservedCommunicationTypes,
selected: communicationTypes,
},
{
section: "DataTypes",
reserved: reservedDataTypes,
selected: dataTypes,
},
]) {
const sectionValue = map[section as "CommunicationTypes" | "DataTypes"];
if (
sectionValue !== undefined &&
(sectionValue === null ||
typeof sectionValue !== "object" ||
Array.isArray(sectionValue))
) {
throw new Error(`${filePath}: ${version}.${section} must be a mapping`);
}
const ids = new Map<number, string>();
for (const [name, value] of Object.entries(sectionValue ?? {})) {
if (reserved.has(name)) {
throw new Error(
`${filePath}: ${version}.${section}.${name} uses a reserved type name`,
);
}
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
throw new Error(
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
);
const id = value as number;
const previous = ids.get(id);
if (previous && previous !== name)
throw new Error(
`${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`,
);
ids.set(id, name);
if (version === root.protocol_version) {
selected.add(name);
}
}
}
}
return {
communicationTypes: [...communicationTypes].sort(),
dataTypes: [...dataTypes].sort(),
};
}
export function generateTypeMapModule(metadata: {
communicationTypes: string[];
dataTypes: string[];
}) {
const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`;
const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`;
return { js, dts };
}
export async function writeTypeMapModule(outDir, typeMapsPath) {
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
throw new Error(
`Failed to read type map '${typeMapsPath}': ${error.message}`,
);
});
const metadata = parseTypeMapYaml(source, typeMapsPath);
const module = generateTypeMapModule(metadata);
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(outDir, typeMapEntryName), module.js);
await fs.writeFile(path.join(outDir, "mtp_type_map.d.ts"), module.dts);
await fs.writeFile(path.join(outDir, `${typeMapEntryName}.d.ts`), module.dts);
return source;
}
export async function copyWasmBuildInputs(buildRoot) {
const inputs = [
"Cargo.lock",
"wasm",
"common",
"codec",
"crypto",
"type-map",
];
for (const input of inputs) {
const source = path.join(packageRoot, input);
if (!(await pathExists(source))) {
continue;
}
await fs.cp(source, path.join(buildRoot, input), { recursive: true });
}
}
async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
const buildRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mtp-wasm-"));
const args = [
"build",
path.join(buildRoot, "wasm"),
"--target",
"web",
"--out-dir",
outDir,
];
if (release) {
args.push("--release");
} else {
args.push("--dev");
}
args.push(...wasmPackArgs);
try {
await copyWasmBuildInputs(buildRoot);
await new Promise<void>((resolve, reject) => {
const child = spawn("wasm-pack", args, {
cwd: buildRoot,
env: {
...process.env,
MTP_TYPE_MAPS: typeMapsPath,
RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"]
.filter(Boolean)
.join(" "),
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
reject(
new Error(
"Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.",
),
);
} else {
reject(error);
}
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim(),
),
);
}
});
});
} finally {
await fs.rm(buildRoot, { recursive: true, force: true });
}
}
async function buildIfNeeded(state, force = false) {
if (state.buildPromise) {
return state.buildPromise;
}
state.buildPromise = (async () => {
const typeMapSource = await writeTypeMapModule(
state.outDir,
state.typeMapsPath,
);
const packageInputs = await hashPackageInputs();
const fingerprint = crypto
.createHash("sha256")
.update(
JSON.stringify({
packageRoot,
packageInputs,
typeMapsPath: state.typeMapsPath,
typeMapSource,
release: state.release,
wasmPackArgs: state.wasmPackArgs,
}),
)
.digest("hex");
const stampPath = path.join(state.outDir, ".mtp-build.json");
const rawEntryPath = path.join(state.outDir, rawEntryName);
const wasmPath = path.join(state.outDir, wasmEntryName);
let previousFingerprint = null;
try {
previousFingerprint = JSON.parse(
await fs.readFile(stampPath, "utf8"),
).fingerprint;
} catch {
previousFingerprint = null;
}
if (
!force &&
previousFingerprint === fingerprint &&
(await pathExists(rawEntryPath)) &&
(await pathExists(wasmPath))
) {
return;
}
console.info(
"\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)",
);
await runWasmPack(state);
console.log(
"\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m",
);
await fs.writeFile(
stampPath,
JSON.stringify(
{ fingerprint, builtAt: new Date().toISOString() },
null,
2,
),
);
})().finally(() => {
state.buildPromise = null;
});
return state.buildPromise;
}
export function mtp(options: MTPVitePluginOptions): VitePlugin {
const normalized = normalizeOptions(options);
const state: any = {
outDir: null,
typeMapsPath: null,
release: true,
wasmPackArgs: normalized.wasmPackArgs ?? [],
buildPromise: null,
};
return {
name: "mtp",
async config(config, env) {
const root = path.resolve(config.root ?? process.cwd());
state.typeMapsPath = path.resolve(root, normalized.typeMaps);
state.outDir = path.resolve(
root,
normalized.outDir ?? path.join("node_modules", ".vite", "mtp"),
);
state.release = normalized.release ?? env.command === "build";
if (!(await pathExists(state.typeMapsPath))) {
throw new Error(
`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`,
);
}
await buildIfNeeded(state);
return {
// The generated wasm-bindgen JavaScript imports its sibling `.wasm`
// by a relative URL. Prebundling it independently lets Vite retain an
// older wrapper while the plugin has rebuilt the wasm binary, which
// produces missing closure-export errors at runtime.
optimizeDeps: {
exclude: ["mtp", "mtp/raw", "mtp/type-map"],
},
resolve: {
preserveSymlinks: true,
alias: {
"mtp/raw": path.join(state.outDir, rawEntryName),
"mtp/type-map": path.join(state.outDir, typeMapEntryName),
},
},
};
},
buildStart() {
(this as any).addWatchFile(state.typeMapsPath);
},
async configureServer(server) {
const wasmPath = path.join(state.outDir, wasmEntryName);
const wasmUrl = devServerPath(server.config.root, wasmPath);
if (wasmUrl) {
server.middlewares.use(async (req, res, next) => {
if (
!req.url ||
new URL(req.url, "http://localhost").pathname !== wasmUrl
) {
next();
return;
}
try {
res.setHeader("Content-Type", "application/wasm");
res.setHeader(
"Cache-Control",
"no-cache, no-store, must-revalidate",
);
res.end(await fs.readFile(wasmPath));
} catch (error) {
next(error);
}
});
}
const sourceWatchPaths = [
"wasm/src",
"common/src",
"codec/src",
"crypto/src",
"type-map/src",
"type-map/reserved.json",
].map((rel) => path.join(packageRoot, rel));
server.watcher.add(state.typeMapsPath);
for (const sourcePath of sourceWatchPaths) {
if (await pathExists(sourcePath)) {
server.watcher.add(sourcePath);
}
}
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRebuild = (changedPath: string) => {
const resolved = path.resolve(changedPath);
const isTypeMap = resolved === state.typeMapsPath;
const isSource = sourceWatchPaths.some(
(sourcePath) =>
resolved === sourcePath ||
resolved.startsWith(`${sourcePath}${path.sep}`),
);
if (!isTypeMap && !isSource) {
return;
}
if (rebuildTimer) {
clearTimeout(rebuildTimer);
}
rebuildTimer = setTimeout(() => {
rebuildTimer = null;
void (async () => {
try {
await server.restart();
} catch (error) {
server.config.logger.error(
error instanceof Error ? error.message : String(error),
);
}
})();
}, 200);
};
server.watcher.on("change", scheduleRebuild);
server.watcher.on("add", scheduleRebuild);
},
};
}
export const mtpVitePlugin = mtp;
export default mtpVitePlugin;