astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
355 lines (354 loc) • 16.1 kB
JavaScript
import nodeFs from "node:fs";
import { fileURLToPath } from "node:url";
import { isRunnableDevEnvironment, normalizePath } from "vite";
import { createContentDataIncrementalMetadata } from "../core/build/incremental-metadata.js";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js";
import { AstroError, AstroErrorData } from "../core/errors/index.js";
import { rootRelativePath } from "../core/viteUtils.js";
import { isAstroClientEnvironment } from "../environments.js";
import { createDefaultAstroMetadata } from "../vite-plugin-astro/metadata.js";
import { ASSET_IMPORTS_FILE, ASSET_IMPORTS_RESOLVED_STUB_ID, ASSET_IMPORTS_VIRTUAL_ID, CONTENT_MODULE_FLAG, CONTENT_RENDER_FLAG, DATA_STORE_CHUNK_FILE_NAME_PATTERN, DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX, DATA_STORE_MANIFEST_FILE, DATA_STORE_VIRTUAL_ID, MODULES_IMPORTS_FILE, MODULES_MJS_ID, MODULES_MJS_VIRTUAL_ID, RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX, RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_SUFFIX, RESOLVED_DATA_STORE_VIRTUAL_ID, RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID } from "./consts.js";
import { getDataStoreChunkSize, getDataStoreDir, getDataStoreFile } from "./paths.js";
import { getContentPaths, isDeferredModule } from "./utils.js";
function invalidateAssetImports(viteServer, filePath) {
const timestamp = Date.now();
for (const environment of Object.values(viteServer.environments)) {
const modules = environment.moduleGraph.getModulesByFile(filePath);
if (modules) {
for (const module of modules) {
environment.moduleGraph.invalidateModule(module, undefined, timestamp, true);
}
}
if (isRunnableDevEnvironment(environment)) {
const runnerModules = environment.runner.evaluatedModules.getModulesByFile(filePath);
if (runnerModules) {
for (const runnerModule of runnerModules) {
environment.runner.evaluatedModules.invalidateModule(runnerModule);
}
}
}
}
}
function invalidateDataStore(viteServer, { notifyClient = true } = {}) {
const timestamp = Date.now();
// Invalidate both the ssr and prerender environments. When an adapter
// enables a separate prerender environment (e.g. Cloudflare with
// `prerenderEnvironment: 'node'`), prerendered pages are served by that
// environment's own module runner and route cache. Skipping it would
// leave the prerender environment serving stale content after edits.
for (const name of [ASTRO_VITE_ENVIRONMENT_NAMES.ssr, ASTRO_VITE_ENVIRONMENT_NAMES.prerender]) {
const environment = viteServer.environments[name];
if (!environment) continue;
const module = environment.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
if (module) {
// Pass `true` to mark this as HMR invalidation so Vite drops cached SSR results.
environment.moduleGraph.invalidateModule(module, undefined, timestamp, true);
}
// Also invalidate the module in the runner's evaluation cache.
// Server-side invalidation only clears `transformResult`, but the runner
// may still hold a stale evaluated result. When the runner's `fetchModule`
// call triggers a fresh server transform, the transform re-populates
// `transformResult` before the runner checks it, causing a false cache hit.
if (isRunnableDevEnvironment(environment)) {
const runnerModule = environment.runner.evaluatedModules.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
if (runnerModule) {
environment.runner.evaluatedModules.invalidateModule(runnerModule);
}
}
// Signal the runner to clear its route cache so that getStaticPaths()
// is re-evaluated with the updated content collection data.
environment.hot.send("astro:content-changed", {});
}
// Only notify the client to reload when data has actually changed at runtime.
// During initial startup (buildStart), no client has loaded content yet, so
// sending a full-reload would just cause a spurious page reload for the first
// browser that connects.
if (notifyClient) {
viteServer.environments.client.hot.send({
type: "full-reload",
path: "*"
});
}
}
// Timestamps of direct (write-driven) invalidations, keyed by file path. The
// file watcher usually observes the same write shortly afterwards; watcher
// events inside this window are echoes of an invalidation that has already
// happened and are skipped so clients don't get two full reloads for one change.
const directInvalidations = new Map();
const DIRECT_INVALIDATION_ECHO_MS = 1e3;
function markDirectInvalidation(path) {
directInvalidations.set(path, Date.now());
}
function isDirectInvalidationEcho(path) {
const time = directInvalidations.get(path);
return time !== undefined && Date.now() - time < DIRECT_INVALIDATION_ECHO_MS;
}
/** The file whose write commits a data store update during dev. */
function getDevDataStoreFile(settings) {
if (getDataStoreChunkSize(settings) !== undefined) {
return new URL(DATA_STORE_MANIFEST_FILE, getDataStoreDir(settings, true));
}
return getDataStoreFile(settings, true);
}
/**
* Invalidates the content virtual modules directly whenever the given store
* writes to disk. The watcher listeners in `configureServer` cover writes from
* other processes, but the watcher can miss the atomic rename that commits a
* write on some platforms (notably Windows, see #17335), leaving dev serving
* stale content until a restart. Subscribing to the store's own write
* notifications makes invalidation of this process's writes deterministic.
*/
export function attachDataStoreInvalidation(store, server, settings) {
const dataStorePath = fileURLToPath(getDevDataStoreFile(settings));
const assetImportsPath = fileURLToPath(new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir));
store.onFileWritten((path) => {
if (path === dataStorePath) {
markDirectInvalidation(dataStorePath);
invalidateDataStore(server);
invalidateAssetImports(server, assetImportsPath);
} else if (path === assetImportsPath) {
markDirectInvalidation(assetImportsPath);
invalidateAssetImports(server, assetImportsPath);
}
});
}
export function astroContentVirtualModPlugin({ settings, fs }) {
let dataStoreDir;
let dataStoreFile;
let devServer;
let liveConfig;
let isDev = false;
return {
name: "astro-content-virtual-mod-plugin",
enforce: "pre",
config(_, env) {
isDev = env.command === "serve";
dataStoreDir = getDataStoreDir(settings, isDev);
if (getDataStoreChunkSize(settings) !== undefined) {
dataStoreFile = new URL(DATA_STORE_MANIFEST_FILE, dataStoreDir);
} else {
dataStoreFile = getDataStoreFile(settings, isDev);
}
const contentPaths = getContentPaths(settings.config, undefined, settings.config.legacy?.collectionsBackwardsCompat);
if (contentPaths.liveConfig.exists) {
liveConfig = normalizePath(fileURLToPath(contentPaths.liveConfig.url));
}
},
buildStart() {
if (devServer) {
const assetImportsPath = fileURLToPath(new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir));
// We defer adding the data store file to the watcher until the server is ready
devServer.watcher.add(fileURLToPath(dataStoreFile));
devServer.watcher.add(assetImportsPath);
// Manually invalidate the data store to avoid a race condition in file watching.
// Skip client reload since no browser has loaded content yet at startup.
invalidateDataStore(devServer, { notifyClient: false });
invalidateAssetImports(devServer, assetImportsPath);
}
},
resolveId: {
filter: { id: new RegExp(`^(${VIRTUAL_MODULE_ID}|${DATA_STORE_VIRTUAL_ID}|${MODULES_MJS_ID}|${ASSET_IMPORTS_VIRTUAL_ID})$|^${DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX}|(?:\\?|&)${CONTENT_MODULE_FLAG}(?:&|=|$)`) },
async handler(id, importer) {
if (id === VIRTUAL_MODULE_ID) {
// Live content config can't import the virtual module directly,
// because it would create a circular dependency from the collection exports.
// Instead, we resolve the config util module, because that's all that it should use anyway.
if (liveConfig && importer && liveConfig === normalizePath(importer)) {
return this.resolve("astro/virtual-modules/live-config", importer, { skipSelf: true });
}
return RESOLVED_VIRTUAL_MODULE_ID;
}
if (id === DATA_STORE_VIRTUAL_ID) {
return RESOLVED_DATA_STORE_VIRTUAL_ID;
}
if (id.startsWith(DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX)) {
const fileName = id.slice(DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX.length);
if (!DATA_STORE_CHUNK_FILE_NAME_PATTERN.test(fileName)) {
this.error(`Invalid data-store chunk: ${fileName}`);
}
return `${RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX}${fileName}${RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_SUFFIX}`;
}
if (isDeferredModule(id)) {
const [, query] = id.split("?");
const params = new URLSearchParams(query);
const fileName = params.get("fileName");
let importPath = undefined;
if (fileName && URL.canParse(fileName, settings.config.root.toString())) {
importPath = fileURLToPath(new URL(fileName, settings.config.root));
}
if (importPath) {
return await this.resolve(`${importPath}?${CONTENT_RENDER_FLAG}`);
}
}
if (id === MODULES_MJS_ID) {
const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
if (fs.existsSync(modules)) {
return {
id: fileURLToPath(modules),
meta: createContentDataIncrementalMetadata()
};
}
return MODULES_MJS_VIRTUAL_ID;
}
if (id === ASSET_IMPORTS_VIRTUAL_ID) {
const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
if (fs.existsSync(assetImportsFile)) {
return {
id: fileURLToPath(assetImportsFile),
meta: createContentDataIncrementalMetadata()
};
}
return ASSET_IMPORTS_RESOLVED_STUB_ID;
}
}
},
load: {
filter: { id: new RegExp(`^(${RESOLVED_VIRTUAL_MODULE_ID}|${RESOLVED_DATA_STORE_VIRTUAL_ID}|${ASSET_IMPORTS_RESOLVED_STUB_ID}|${MODULES_MJS_VIRTUAL_ID})$|^${RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX}`) },
async handler(id) {
if (id.startsWith(RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX)) {
const resolvedFileName = id.slice(RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX.length);
if (!resolvedFileName.endsWith(RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_SUFFIX)) {
this.error(`Invalid data-store chunk: ${resolvedFileName}`);
}
const fileName = resolvedFileName.slice(0, -RESOLVED_DATA_STORE_CHUNK_VIRTUAL_ID_SUFFIX.length);
if (!DATA_STORE_CHUNK_FILE_NAME_PATTERN.test(fileName)) {
this.error(`Invalid data-store chunk: ${fileName}`);
}
const contents = await fs.promises.readFile(new URL(`./${fileName}`, dataStoreDir), "utf-8");
return {
code: `export default ${JSON.stringify(contents)}`,
map: { mappings: "" }
};
}
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const isClient = isAstroClientEnvironment(this.environment);
const code = await generateContentEntryFile({
settings,
fs,
isClient
});
const astro = createDefaultAstroMetadata();
astro.propagation = "in-tree";
return {
code,
meta: { astro }
};
}
if (id === RESOLVED_DATA_STORE_VIRTUAL_ID) {
if (!fs.existsSync(dataStoreFile)) {
return {
code: "export default new Map()",
meta: createContentDataIncrementalMetadata()
};
}
const jsonData = await fs.promises.readFile(dataStoreFile, "utf-8");
if (getDataStoreChunkSize(settings) !== undefined) {
try {
const manifest = JSON.parse(jsonData);
// Emit each part as a lazy virtual import so the parts stay separate
// chunks instead of being inlined into one huge module (the very
// thing chunking avoids). manifestToMap reads the resolved
// namespace's `default` export back into the concatenated string.
const chunkImport = (fileName) => `(await import(${JSON.stringify(`${DATA_STORE_CHUNK_VIRTUAL_ID_PREFIX}${fileName}`)}))`;
const entries = Object.entries(manifest).map(([collection, parts]) => `${JSON.stringify(collection)}:[${parts.map(chunkImport).join(",")}]`);
const code = `export default{${entries.join(",")}}`;
// Tag as content-data so the incremental route hash prunes this
// module and the chunk modules it lazily imports; content edits are
// tracked through each page's `cacheKey`, not the dependency hash.
return {
code,
map: { mappings: "" },
meta: createContentDataIncrementalMetadata()
};
} catch (err) {
const message = "Could not parse data store manifest JSON file";
this.error({
message,
id,
cause: err
});
}
}
try {
// Validate here so a corrupt store fails loudly with this error
// instead of being swallowed by the runtime's fallback to an
// empty store.
JSON.parse(jsonData);
} catch (err) {
const message = "Could not parse JSON file";
this.error({
message,
id,
cause: err
});
}
// A JSON string parsed at runtime keeps the module's AST tiny; an
// object literal grows with the store and can exceed what the
// NAPI bridge can convert during dev SSR transforms (#17220),
// and is slower for V8 to parse than JSON.parse.
//
// > Quoted from https://v8.dev/blog/cost-of-javascript-2019
// >
// > JSON.parse('…') is much faster to parse, compile, and execute
// compared to an equivalent JavaScript literal — not just in V8
// (1.7× as fast), but in all major JavaScript engines.
return {
code: `export default JSON.parse(${JSON.stringify(jsonData)})`,
map: { mappings: "" },
meta: createContentDataIncrementalMetadata()
};
}
if (id === ASSET_IMPORTS_RESOLVED_STUB_ID) {
const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
return {
code: fs.existsSync(assetImportsFile) ? fs.readFileSync(assetImportsFile, "utf-8") : "export default new Map()",
meta: createContentDataIncrementalMetadata()
};
}
if (id === MODULES_MJS_VIRTUAL_ID) {
const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
return {
code: fs.existsSync(modules) ? fs.readFileSync(modules, "utf-8") : "export default new Map()",
meta: createContentDataIncrementalMetadata()
};
}
}
},
configureServer(server) {
devServer = server;
const dataStorePath = fileURLToPath(dataStoreFile);
const assetImportsPath = fileURLToPath(new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir));
server.watcher.on("add", (addedPath) => {
if (addedPath === dataStorePath && !isDirectInvalidationEcho(dataStorePath)) {
invalidateDataStore(server);
invalidateAssetImports(server, assetImportsPath);
}
});
server.watcher.on("change", (changedPath) => {
if (changedPath === dataStorePath) {
if (isDirectInvalidationEcho(dataStorePath)) {
return;
}
invalidateDataStore(server);
invalidateAssetImports(server, assetImportsPath);
} else if (changedPath === assetImportsPath && !isDirectInvalidationEcho(assetImportsPath)) {
invalidateAssetImports(server, assetImportsPath);
}
});
}
};
}
async function generateContentEntryFile({ settings, isClient }) {
const contentPaths = getContentPaths(settings.config, undefined, settings.config.legacy?.collectionsBackwardsCompat);
const relContentDir = rootRelativePath(settings.config.root, contentPaths.contentDir);
let virtualModContents;
if (isClient) {
throw new AstroError({
...AstroErrorData.ServerOnlyModule,
message: AstroErrorData.ServerOnlyModule.message("astro:content")
});
} else {
virtualModContents = nodeFs.readFileSync(contentPaths.virtualModTemplate, "utf-8").replace("@@CONTENT_DIR@@", relContentDir).replace("/* @@LIVE_CONTENT_CONFIG@@ */", contentPaths.liveConfig.exists ? `const liveCollections = (await import(${JSON.stringify(fileURLToPath(contentPaths.liveConfig.url))})).collections;` : "const liveCollections = {};");
}
return virtualModContents;
}