wxt
Version:
⚡ Next-gen Web Extension Framework
265 lines (264 loc) • 9.02 kB
JavaScript
import { debounce } from "perfect-debounce";
import chokidar from "chokidar";
import { getEntrypointBundlePath, isHtmlEntrypoint } from "./utils/entrypoints.mjs";
import {
getContentScriptCssFiles,
getContentScriptsCssMap
} from "./utils/manifest.mjs";
import {
internalBuild,
detectDevChanges,
rebuild,
findEntrypoints
} from "./utils/building/index.mjs";
import { createExtensionRunner } from "./runners/index.mjs";
import { Mutex } from "async-mutex";
import pc from "picocolors";
import { relative } from "node:path";
import { deinitWxtModules, initWxtModules, registerWxt, wxt } from "./wxt.mjs";
import { unnormalizePath } from "./utils/paths.mjs";
import {
getContentScriptJs,
mapWxtOptionsToRegisteredContentScript
} from "./utils/content-scripts.mjs";
import { createKeyboardShortcuts } from "./keyboard-shortcuts.mjs";
import { isBabelSyntaxError, logBabelSyntaxError } from "./utils/syntax-errors.mjs";
export async function createServer(inlineConfig) {
await registerWxt("serve", inlineConfig);
wxt.server = await createServerInternal();
await wxt.hooks.callHook("server:created", wxt, wxt.server);
return wxt.server;
}
async function createServerInternal() {
const getServerInfo = () => {
const { host, port, origin } = wxt.config.dev.server;
return { host, port, origin };
};
let [runner, builderServer] = await Promise.all([
createExtensionRunner(),
wxt.builder.createServer(getServerInfo())
]);
let wasStopped = false;
const server = {
get host() {
return getServerInfo().host;
},
get port() {
return getServerInfo().port;
},
get origin() {
return getServerInfo().origin;
},
get watcher() {
return builderServer.watcher;
},
get ws() {
return builderServer.ws;
},
currentOutput: void 0,
async start() {
if (wasStopped) {
await wxt.reloadConfig();
runner = await createExtensionRunner();
builderServer = await wxt.builder.createServer(getServerInfo());
await initWxtModules();
}
await builderServer.listen();
const hostInfo = server.host === "localhost" ? "" : ` (listening on ${server.host})`;
wxt.logger.success(`Started dev server @ ${server.origin}${hostInfo}`);
await wxt.hooks.callHook("server:started", wxt, server);
server.ws.on("wxt:background-initialized", () => {
if (server.currentOutput == null) return;
reloadContentScripts(server.currentOutput.steps, server);
});
await buildAndOpenBrowser();
const reloadOnChange = createFileReloader(server);
server.watcher.on("all", async (...args) => {
await reloadOnChange(args[0], args[1]);
keyboardShortcuts.start();
});
keyboardShortcuts.printHelp({
canReopenBrowser: !wxt.config.runnerConfig.config.disabled && !!runner.canOpen?.()
});
},
async stop() {
wasStopped = true;
keyboardShortcuts.stop();
await runner.closeBrowser();
await builderServer.close();
await wxt.hooks.callHook("server:closed", wxt, server);
deinitWxtModules();
server.currentOutput = void 0;
},
async restart() {
await server.stop();
await server.start();
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
},
reloadContentScript(payload) {
server.ws.send("wxt:reload-content-script", payload);
},
reloadPage(path) {
server.ws.send("wxt:reload-page", path);
},
reloadExtension() {
server.ws.send("wxt:reload-extension");
},
async restartBrowser() {
await runner.closeBrowser();
keyboardShortcuts.stop();
await wxt.reloadConfig();
runner = await createExtensionRunner();
await runner.openBrowser();
keyboardShortcuts.start();
}
};
const keyboardShortcuts = createKeyboardShortcuts(server);
const buildAndOpenBrowser = async () => {
try {
server.currentOutput = await internalBuild();
} catch (err) {
if (!isBabelSyntaxError(err)) {
throw err;
}
logBabelSyntaxError(err);
wxt.logger.info("Waiting for syntax error to be fixed...");
await new Promise((resolve) => {
const watcher = chokidar.watch(err.id, { ignoreInitial: true });
watcher.on("all", () => {
watcher.close();
wxt.logger.info("Syntax error resolved, rebuilding...");
resolve();
});
});
return buildAndOpenBrowser();
}
try {
server.watcher.add(getExternalOutputDependencies(server));
} catch (err) {
wxt.config.logger.warn("Failed to register additional file paths:", err);
}
await runner.openBrowser();
};
builderServer.on?.("close", () => keyboardShortcuts.stop());
return server;
}
function createFileReloader(server) {
const fileChangedMutex = new Mutex();
const changeQueue = [];
const cb = async (event, path) => {
changeQueue.push([event, path]);
const reloading = fileChangedMutex.runExclusive(async () => {
if (server.currentOutput == null) return;
const fileChanges = changeQueue.splice(0, changeQueue.length).map(([_, file]) => file);
if (fileChanges.length === 0) return;
await wxt.reloadConfig();
const changes = detectDevChanges(fileChanges, server.currentOutput);
if (changes.type === "no-change") return;
if (changes.type === "full-restart") {
wxt.logger.info("Config changed, restarting server...");
server.restart();
return;
}
if (changes.type === "browser-restart") {
wxt.logger.info("Runner config changed, restarting browser...");
server.restartBrowser();
return;
}
wxt.logger.info(
`Changed: ${Array.from(new Set(fileChanges)).map((file) => pc.dim(relative(wxt.config.root, file))).join(", ")}`
);
const allEntrypoints = await findEntrypoints();
try {
const { output: newOutput } = await rebuild(
allEntrypoints,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput
);
server.currentOutput = newOutput;
switch (changes.type) {
case "extension-reload":
server.reloadExtension();
wxt.logger.success(`Reloaded extension`);
break;
case "html-reload":
const { reloadedNames } = reloadHtmlPages(
changes.rebuildGroups,
server
);
wxt.logger.success(`Reloaded: ${getFilenameList(reloadedNames)}`);
break;
case "content-script-reload":
reloadContentScripts(changes.changedSteps, server);
const rebuiltNames = changes.rebuildGroups.flat().map((entry) => entry.name);
wxt.logger.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
break;
}
} catch {
}
});
await reloading.catch((error) => {
if (!isBabelSyntaxError(error)) {
throw error;
}
logBabelSyntaxError(error);
});
};
return debounce(cb, wxt.config.dev.server.watchDebounce, {
leading: true,
trailing: false
});
}
function reloadContentScripts(steps, server) {
if (wxt.config.manifestVersion === 3) {
steps.forEach((step) => {
if (server.currentOutput == null) return;
const entry = step.entrypoints;
if (Array.isArray(entry) || entry.type !== "content-script") return;
const js = getContentScriptJs(wxt.config, entry);
const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]);
const css = getContentScriptCssFiles([entry], cssMap);
server.reloadContentScript({
registration: entry.options.registration,
contentScript: mapWxtOptionsToRegisteredContentScript(
entry.options,
js,
css
)
});
});
} else {
server.reloadExtension();
}
}
function reloadHtmlPages(groups, server) {
const htmlEntries = groups.flat().filter(isHtmlEntrypoint);
htmlEntries.forEach((entry) => {
const path = getEntrypointBundlePath(entry, wxt.config.outDir, ".html");
server.reloadPage(path);
});
return {
reloadedNames: htmlEntries.map((entry) => entry.name)
};
}
function getFilenameList(names) {
return names.map((name) => {
return pc.cyan(name);
}).join(pc.dim(", "));
}
function getExternalOutputDependencies(server) {
return server.currentOutput?.steps.flatMap((step, i) => {
if (Array.isArray(step.entrypoints) && i === 0) {
return [];
}
return step.chunks.flatMap((chunk) => {
if (chunk.type === "asset") return [];
return chunk.moduleIds;
});
}).filter(
(file) => !file.includes("node_modules") && !file.startsWith("\0")
).map(unnormalizePath).filter((file) => !file.startsWith(wxt.config.root)) ?? [];
}