shark-mvc
Version:
Shark-MVC is a small MVC framework born to make our small JS apps easier to build.
384 lines (317 loc) • 14.7 kB
JavaScript
import fs from "fs";
import path from "path";
import MagicString from "magic-string";
// Matches Shark's self-registration convention: `Shark.registerComponent(x)`,
// `Shark.registerView(x)`, `Shark.registerController(x)`, `Shark.registerPageTemplate(x)`.
const REGISTER_CALL_RE = /Shark\s*\.\s*register(?:Component|Controller|View|PageTemplate)\s*\(/;
// Matches `Shark.registerModel("ModelName", ModelClass)`, capturing the literal name string -
// only a plain string/template literal is supported, since the name has to be re-embedded as a
// literal in the injected snippet below.
const REGISTER_MODEL_RE = /Shark\s*\.\s*registerModel\s*\(\s*["'`]([^"'`]+)["'`]/;
const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
const TRANSFORMABLE_EXT_RE = /\.(m?jsx?|tsx?)$/;
const VIRTUAL_AUTO_COMPONENTS_ID = "virtual:shark-mvc-auto-components";
const RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID = "\0" + VIRTUAL_AUTO_COMPONENTS_ID;
/**
* Normalizes a user-supplied `componentsFolder` (e.g. "./src/components", "src/components/",
* "/src/components", or the Windows-style equivalents) into the project-root-relative form
* `import.meta.glob` expects (e.g. "/src/components").
*/
function toRootGlobPrefix (folder) {
let normalized = folder.replace(/\\/g, "/").replace(/\/+$/, "");
if (normalized.startsWith("./")) {
normalized = normalized.slice(1);
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
return normalized;
}
const SCRIPT_TAG_RE = /<script\b([^>]*)>/gi;
const TYPE_MODULE_ATTR_RE = /\btype\s*=\s*["']module["']/i;
const SRC_ATTR_RE = /\bsrc\s*=\s*["']([^"']+)["']/i;
/**
* Finds the `<script type="module" src="...">` entry point(s) declared in an HTML file, resolved
* to absolute paths: root-relative ("/src/main.js") against `rootDir`, anything else against the
* HTML file's own directory - matching how a browser would resolve the same `src`.
*/
function findHtmlEntryModules (htmlFilePath, rootDir) {
let htmlContent;
try {
htmlContent = fs.readFileSync(htmlFilePath, "utf-8");
}
catch {
return [];
}
const entries = [];
let match;
SCRIPT_TAG_RE.lastIndex = 0;
while ((match = SCRIPT_TAG_RE.exec(htmlContent))) {
const attrs = match[1];
if (!TYPE_MODULE_ATTR_RE.test(attrs)) {
continue;
}
const srcMatch = attrs.match(SRC_ATTR_RE);
if (!srcMatch || /^(https?:)?\/\//i.test(srcMatch[1])) {
continue;
}
const src = srcMatch[1];
const absolutePath = src.startsWith("/") ? path.join(rootDir, src) : path.resolve(path.dirname(htmlFilePath), src);
entries.push(absolutePath);
}
return entries;
}
/**
* Resolves the set of JS entry module absolute paths for the project, so the auto-import glob
* can be injected directly into them - mirroring Vite's own default of treating `<root>/index.html`
* as the entry when `build.rollupOptions.input` isn't set, while also supporting a custom `input`
* (a single path, an array, or a named-entries object) pointing at HTML or JS/TS files directly.
*/
function resolveEntryModulePaths (config) {
const rootDir = config.root || process.cwd();
const input = config.build?.rollupOptions?.input;
const inputPaths = !input ? [path.join(rootDir, "index.html")] : (Array.isArray(input) ? input : (typeof input === "string" ? [input] : Object.values(input)));
const entries = new Set();
for (const inputPath of inputPaths) {
const absoluteInputPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(rootDir, inputPath);
if (/\.html?$/i.test(absoluteInputPath)) {
findHtmlEntryModules(absoluteInputPath, rootDir).forEach(entry => entries.add(normalizePathSlashes(entry)));
}
else {
entries.add(normalizePathSlashes(absoluteInputPath));
}
}
return entries;
}
function normalizePathSlashes (filePath) {
return filePath.replace(/\\/g, "/");
}
const HMR_SNIPPET = `
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (!newModule?.default || !Shark.hotUpdate(newModule.default)) {
import.meta.hot.invalidate();
}
});
}
`;
function modelHmrSnippet (modelName) {
const literalName = JSON.stringify(modelName);
return `
if (import.meta.hot) {
import.meta.hot.dispose(() => { Shark.__expectModelHotUpdate(${literalName}); });
import.meta.hot.accept((newModule) => {
if (!newModule?.default || !Shark.hotUpdateModel(${literalName}, newModule.default)) {
import.meta.hot.invalidate();
}
});
}
`;
}
const MAX_CHAIN_DEPTH = 100;
/**
* Mirrors Vite's own HMR boundary search (the `propagateUpdate` walk in Vite's dev server) to
* find out *why* a specific module would force a full reload, instead of just whether *some*
* cycle exists somewhere in its ancestry.
*
* The walk goes up a module's `.importers` (who imports this module), and for each branch:
* - stops immediately if it reaches a module that `.isSelfAccepting` - that branch is fine
* (this is what makes a plain "is this module part of a cycle at all" check too broad: a
* module can be transitively connected to a distant cycle through `.importers` while still
* hot-updating perfectly fine, because a self-accepting boundary is found first);
* - reports "circular" if it revisits a module already on *that same branch*;
* - reports "no-boundary" if it runs out of importers (reached the app's entry) without ever
* finding a self-accepting module.
* The first bad branch found wins and stops the search, matching Vite's own short-circuiting
* behavior: one broken import path is enough to force a full reload even when other paths to
* the same module are perfectly fine.
*
* @returns {{ type: "circular" | "no-boundary", chain: import("vite").ModuleNode[] } | null}
*/
function findReloadCause (startModule) {
function walk (mod, chain) {
if (mod.isSelfAccepting) {
return null;
}
if (chain.length > MAX_CHAIN_DEPTH) {
return null; // bail out of pathological/very deep graphs rather than hang
}
if (mod.importers.size === 0) {
return { type: "no-boundary", chain };
}
for (const importer of mod.importers) {
if (chain.includes(importer)) {
return { type: "circular", chain: chain.concat(importer) };
}
const result = walk(importer, chain.concat(importer));
if (result) {
return result;
}
}
return null;
}
return walk(startModule, [startModule]);
}
function moduleLabel (mod) {
return mod.url || mod.id || mod.file || "<unknown module>";
}
/**
* Vite plugin that gives Shark-MVC Components/Views/Controllers/PageTemplates real HMR with
* zero per-file boilerplate: write your `Shark.registerXxx(instance)` call as usual, and this
* plugin injects the `import.meta.hot.accept(...)` wiring for you at dev-server transform time.
*
* It has to work this way (rather than a shared runtime helper) because Vite only treats a
* module as a fully reliable HMR boundary ("self-accepts") when it can see
* `import.meta.hot.accept(...)` in the module's own code *after all transforms have run* -
* injecting it here, before Vite's own import-analysis pass, satisfies that. Calling a shared
* `Shark.acceptHot()`-style function at runtime instead does not: Vite classifies it as generic
* "detected api usage", which fails to isolate updates for a module reachable via a circular
* import anywhere else in the graph (common when Views/Components reference each other for
* navigation) - see `Shark.acceptHot`'s doc comment for the full explanation.
*
* No effect on production builds: `import.meta.hot` is always `undefined` there, so bundlers
* dead-code-eliminate the injected block regardless.
*
* It also (unless disabled) makes sure every Component under `componentsFolder` actually ends up
* in the app, even ones no View/Component ever `import`s in JS ("standalone" components only
* ever referenced by a `<shark-xxx>` tag in some template). Bundlers only include a module because
* something in the import graph reaches it - a name appearing in an HTML template string doesn't
* count - so without this, such a component's self-registration (`Shark.registerComponent(...)`)
* simply never runs, and RenderableJaw's auto component-tag binding can never find it. This
* injects a single `import.meta.glob(componentsFolder + "/**\/*.{js,jsx,mjs,ts,tsx}", { eager:
* true })` (Vite's own glob import, which it expands by actually scanning the folder) into a
* virtual module, and adds a `<script type="module">` tag importing it to the HTML entry - so
* every matching file is eagerly imported, and self-registers, with zero manual imports anywhere
* in your own code. Works in both dev and production builds: the import is injected directly into
* the app's own entry module (found by parsing the `<script type="module" src="...">` tag(s) in
* `index.html`, or `build.rollupOptions.input` if set), exactly like the manual import you'd
* otherwise add to it by hand - so it's just as much a part of the module graph as anything else.
*
* @param {object} [options]
* @param {boolean} [options.autoImportComponents=true] - Set to `false` to disable the
* auto-import behavior described above, e.g. if you'd rather list components manually.
* @param {string} [options.componentsFolder="./src/components"] - The folder (relative to the
* Vite project root) containing your Components, scanned recursively.
*
* @example
* // vite.config.js
* import { defineConfig } from "vite";
* import sharkHMR from "shark-mvc/vite-plugin";
*
* export default defineConfig({
* plugins: [sharkHMR()],
* // or, to customize:
* // plugins: [sharkHMR({ autoImportComponents: true, componentsFolder: "./src/components" })],
* });
*/
export default function sharkHMR ({ autoImportComponents = true, componentsFolder = "./src/components" } = {}) {
let isDev = false;
let entryModulePaths = new Set();
const componentsGlobPrefix = toRootGlobPrefix(componentsFolder);
const componentsGlobPattern = componentsGlobPrefix + "/**/*.{js,jsx,mjs,ts,tsx}";
return {
name: "shark-mvc:hmr",
configResolved (config) {
isDev = config.command === "serve";
if (!autoImportComponents) {
return;
}
const absoluteComponentsFolder = path.join(config.root, componentsGlobPrefix);
if (!fs.existsSync(absoluteComponentsFolder)) {
config.logger.warn(
`\n[shark-mvc:hmr] autoImportComponents is enabled but "${absoluteComponentsFolder}" doesn't exist - no Component will be auto-imported. Pass the right componentsFolder, or autoImportComponents: false to silence this.\n`,
{ timestamp: true }
);
}
entryModulePaths = resolveEntryModulePaths(config);
if (entryModulePaths.size === 0) {
config.logger.warn(
`\n[shark-mvc:hmr] autoImportComponents couldn't find an entry module to auto-import components into (looked for a <script type="module" src="..."> in index.html, or build.rollupOptions.input). No Component will be auto-imported.\n`,
{ timestamp: true }
);
}
},
resolveId (id) {
if (autoImportComponents && id === VIRTUAL_AUTO_COMPONENTS_ID) {
return RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID;
}
return null;
},
load (id) {
if (id !== RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID) {
return null;
}
// Vite expands this itself (it scans the folder and eagerly imports every match) - the
// pattern just needs to be a literal string at the point Vite parses this module's code,
// which it is, even though componentsGlobPattern was built dynamically from options above.
return `import.meta.glob(${JSON.stringify(componentsGlobPattern)}, { eager: true });\n`;
},
transform (code, id) {
const [pathWithoutQuery] = id.split("?");
let output = code;
let mutated = false;
if (autoImportComponents && entryModulePaths.has(normalizePathSlashes(pathWithoutQuery))) {
// Same effect as the developer adding this import to their own entry file by hand -
// it's now a real, ordinary part of the module graph, not a separate bolt-on chunk,
// so it works identically in dev and in a production build.
output = `import ${JSON.stringify(VIRTUAL_AUTO_COMPONENTS_ID)};\n` + output;
mutated = true;
}
if (!isDev) {
return mutated ? output : null;
}
if (NODE_MODULES_RE.test(id) || !TRANSFORMABLE_EXT_RE.test(pathWithoutQuery)) {
return mutated ? output : null;
}
if (code.includes("import.meta.hot.accept(")) {
// The developer already wired HMR for it manually - don't inject a second,
// conflicting accept() call.
return mutated ? output : null;
}
const modelMatch = code.match(REGISTER_MODEL_RE);
const isJaw = REGISTER_CALL_RE.test(code);
if (!modelMatch && !isJaw) {
// Doesn't self-register a Jaw or a Model - nothing for this plugin to do.
return mutated ? output : null;
}
const s = new MagicString(output);
s.append(modelMatch ? modelHmrSnippet(modelMatch[1]) : HMR_SNIPPET);
return {
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
};
},
// Runs on every file save, before Vite decides how to propagate the update. If the
// changed file needs a full reload, explain exactly why: either a circular import along
// its specific boundary-search path, or no self-accepting module anywhere up to the
// app's entry. Silent otherwise - a module being *transitively reachable* from some
// cycle elsewhere in the graph doesn't necessarily mean *this* update is affected by it.
handleHotUpdate ({ modules, server }) {
const seen = new Set();
for (const mod of modules) {
const result = findReloadCause(mod);
if (!result) {
continue;
}
const key = `${result.type}:${result.chain.map(moduleLabel).join(">")}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const chain = result.chain.map(moduleLabel).join("\n is imported by ");
if (result.type === "circular") {
server.config.logger.warn(
`\n[shark-mvc:hmr] "${moduleLabel(mod)}" needs a full reload: circular import along this path:\n\n ${chain}\n`,
{ timestamp: true }
);
}
else {
server.config.logger.warn(
`\n[shark-mvc:hmr] "${moduleLabel(mod)}" needs a full reload: no module accepts the update anywhere along this import chain (reached the app's entry without finding one):\n\n ${chain}\n`,
{ timestamp: true }
);
}
}
},
};
}