msw-mock-gen
Version:
A Vite plugin that automatically generates MSW (Mock Service Worker) handlers from your API calls by watching TypeScript/JavaScript files and parsing URL patterns. Supports Vite 2+ and Node.js 12+ for maximum compatibility.
425 lines (423 loc) • 19.7 kB
JavaScript
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
import { glob } from "glob";
import { createHash } from "crypto";
import { parseURLsFromFile, generateMSWHandlers } from "./parser";
import { sterilize } from "./sterilize";
import { parseQueryMutationFiles, generateMockDataFiles, } from "./data-generation";
/**
* MSW Mock Generator - A Vite plugin that automatically generates MSW (Mock Service Worker) handlers
* from your API calls by watching specified folders and parsing TypeScript/JavaScript files for URL patterns.
*
* @param options - Configuration options for the plugin
* @returns Vite plugin instance
*
* @example
* ```typescript
* // vite.config.ts
* import { defineConfig } from 'vite';
* import mswMockGen from 'msw-mock-gen';
*
* export default defineConfig({
* plugins: [
* mswMockGen({
* configs: [
* {
* watchFolder: 'src/data/queries',
* outputFolder: 'src/data/queries/mocks',
* outputFileName: 'mswHandlers.generated',
* excludePatterns: ['navigate({', 'to: "/'] // Config-specific patterns
* }
* ],
* quiet: true,
* mergeHandlers: true,
* outputFolder: 'src/mocks',
* outputFileName: 'mswHandlers.generated',
* excludePatterns: ['navigate({', 'href: "/'] // Global patterns (applied to all configs)
* })
* ]
* });
* ```
*/
export default function mswMockGen(options = { configs: [] }) {
const { configs = [], quiet = true, mergeHandlers = true, outputFolder: topLevelOutputFolder = "src/mocks", outputFileName: topLevelOutputFileName = "mswHandlers.generated", excludePatterns: globalExcludePatterns = [], formatScript, } = options;
// Default config if none provided
const defaultConfig = {
watchFolder: "src/data/queries",
outputFolder: "src/data/queries/mocks",
outputFileName: "mswHandlers.generated",
excludePatterns: [],
};
// Use default config if no configs provided, otherwise use provided configs
const finalConfigs = configs.length === 0 ? [defaultConfig] : configs;
let projectRoot;
let isInitialized = false;
/**
* Creates a hash of the full path to ensure unique cache directory names
* @param path - The path to hash
* @returns A short hash string
*/
const hashPath = (path) => {
return createHash("md5").update(path).digest("hex").substring(0, 8);
};
/**
* Logs messages to console if quiet mode is disabled
* @param args - Arguments to log
*/
const log = (...args) => {
if (!quiet) {
console.log(...args);
}
};
/**
* Runs the format script if specified
* @param root - Project root directory
*/
const runFormatScript = async (root) => {
if (!formatScript) {
return;
}
try {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
log(`MSW Mock Gen: Running format script: ${formatScript}`);
const startTime = Date.now();
await execAsync(`npm run ${formatScript}`, { cwd: root });
const endTime = Date.now();
const duration = endTime - startTime;
log(`MSW Mock Gen: Format script completed (${duration}ms)`);
}
catch (error) {
console.error(`MSW Mock Gen: Error running format script:`, error);
}
};
/**
* Merges multiple handler files into a single output
* @param root - Project root directory
* @param configs - Array of configurations
*/
const mergeAllHandlers = async (root, configs) => {
if (!mergeHandlers || configs.length <= 1) {
return;
}
const startTime = Date.now();
log("MSW Mock Gen: Starting handler merging...");
const topLevelOutputPath = join(root, topLevelOutputFolder);
// Ensure top-level output directory exists
if (!existsSync(topLevelOutputPath)) {
try {
mkdirSync(topLevelOutputPath, { recursive: true });
}
catch {
// Fallback for older Node.js versions that don't support recursive option
const pathParts = topLevelOutputPath.split("/");
let currentPath = "";
for (const part of pathParts) {
if (part) {
currentPath += "/" + part;
if (!existsSync(currentPath)) {
mkdirSync(currentPath);
}
}
}
}
}
const allQueryHandlers = [];
const allMutationHandlers = [];
const allImports = new Set();
// Collect handlers from all configs
for (const config of configs) {
const { watchFolder = "src/data/queries", outputFolder = "src/data/queries/mocks", } = config;
const watchPath = join(root, watchFolder);
// When merging is enabled, read from .cache subdirectories
// Use the same hash-based approach for consistency
// Otherwise, use the original output folder
const actualOutputFolder = mergeHandlers
? join(topLevelOutputFolder, ".cache", hashPath(watchPath))
: outputFolder;
const configOutputPath = join(root, actualOutputFolder);
// Check if the config's output files exist
const queryHandlersFile = join(configOutputPath, "queryHandlers.generated.ts");
const mutationHandlersFile = join(configOutputPath, "mutationHandlers.generated.ts");
if (existsSync(queryHandlersFile)) {
try {
const content = readFileSync(queryHandlersFile, "utf-8");
// Extract import statements (excluding MSW imports)
const importMatches = content.matchAll(/import\s+{[^}]+}\s+from\s+['"`][^'"`]+['"`];/g);
for (const match of importMatches) {
if (!match[0].includes("msw")) {
allImports.add(match[0]);
}
}
// Extract the actual handlers (skip import statements)
const handlersMatch = content.match(/export const queryHandlers = \[([\s\S]*?)\];/);
if (handlersMatch && handlersMatch[1].trim()) {
allQueryHandlers.push(handlersMatch[1].trim());
}
}
catch (error) {
console.error(`MSW Mock Gen: Error reading query handlers from ${queryHandlersFile}:`, error);
}
}
if (existsSync(mutationHandlersFile)) {
try {
const content = readFileSync(mutationHandlersFile, "utf-8");
// Extract import statements (excluding MSW imports)
const importMatches = content.matchAll(/import\s+{[^}]+}\s+from\s+['"`][^'"`]+['"`];/g);
for (const match of importMatches) {
if (!match[0].includes("msw")) {
allImports.add(match[0]);
}
}
// Extract the actual handlers (skip import statements)
const handlersMatch = content.match(/export const mutationHandlers = \[([\s\S]*?)\];/);
if (handlersMatch && handlersMatch[1].trim()) {
allMutationHandlers.push(handlersMatch[1].trim());
}
}
catch (error) {
console.error(`MSW Mock Gen: Error reading mutation handlers from ${mutationHandlersFile}:`, error);
}
}
}
// Generate merged files
const importStatements = Array.from(allImports).join("\n");
const mergedQueryHandlers = allQueryHandlers.length > 0
? `import { http, HttpResponse } from 'msw';
${importStatements ? `\n${importStatements}\n` : ""}
export const queryHandlers = [
${allQueryHandlers.join(",\n")}
];`
: `export const queryHandlers = [];`;
const mergedMutationHandlers = allMutationHandlers.length > 0
? `import { http, HttpResponse } from 'msw';
${importStatements ? `\n${importStatements}\n` : ""}
export const mutationHandlers = [
${allMutationHandlers.join(",\n")}
];`
: `export const mutationHandlers = [];`;
const mergedIndexFile = `import { queryHandlers } from './queryHandlers.generated';
import { mutationHandlers } from './mutationHandlers.generated';
export const handlers = [
...queryHandlers,
...mutationHandlers
];`;
try {
// Write merged query handlers
const mergedQueryHandlersFile = join(topLevelOutputPath, "queryHandlers.generated.ts");
writeFileSync(mergedQueryHandlersFile, mergedQueryHandlers, "utf-8");
// Write merged mutation handlers
const mergedMutationHandlersFile = join(topLevelOutputPath, "mutationHandlers.generated.ts");
writeFileSync(mergedMutationHandlersFile, mergedMutationHandlers, "utf-8");
// Write merged index file
const mergedIndexFilePath = join(topLevelOutputPath, `${topLevelOutputFileName}.ts`);
writeFileSync(mergedIndexFilePath, mergedIndexFile, "utf-8");
const endTime = Date.now();
const duration = endTime - startTime;
log(`MSW Mock Gen: Generated merged handlers at ${topLevelOutputPath} (${duration}ms)`);
}
catch (error) {
console.error(`MSW Mock Gen: Error writing merged handlers files:`, error);
}
};
/**
* Generates MSW handlers for a single configuration
* @param root - Project root directory
* @param config - Configuration for this watch/output folder pair
*/
const generateHandlers = async (root, config) => {
const { watchFolder = "src/data/queries", outputFolder = "src/data/queries/mocks", outputFileName = "mswHandlers.generated", excludePatterns: configExcludePatterns = [], } = config;
// Merge global and config-specific exclude patterns
const excludePatterns = [
...globalExcludePatterns,
...configExcludePatterns,
];
const watchPath = join(root, watchFolder);
// When merging is enabled, output to .cache subdirectory in the merged output folder
// Use a hash of the full path to ensure unique cache directory names
// Otherwise, use the original output folder
const actualOutputFolder = mergeHandlers
? join(topLevelOutputFolder, ".cache", hashPath(watchPath))
: outputFolder;
const outputPath = join(root, actualOutputFolder);
if (!existsSync(watchPath)) {
log(`MSW Mock Gen: Watch folder ${watchPath} does not exist`);
return;
}
// Ensure output directory exists
if (!existsSync(outputPath)) {
try {
mkdirSync(outputPath, { recursive: true });
}
catch {
// Fallback for older Node.js versions that don't support recursive option
const pathParts = outputPath.split("/");
let currentPath = "";
for (const part of pathParts) {
if (part) {
currentPath += "/" + part;
if (!existsSync(currentPath)) {
mkdirSync(currentPath);
}
}
}
}
}
// Find all TypeScript/JavaScript files in the watch folder
const files = await glob("**/*.{ts,js,tsx,jsx}", {
cwd: watchPath,
absolute: true,
ignore: [
`**/${outputFolder}/**`,
`**/*.generated.{ts,js,tsx,jsx}`,
`**/*.generated/**`,
`**/*.mocks.gen.{ts,js,tsx,jsx}`,
`**/*.mocks.gen/**`,
],
});
const allUrls = [];
const allQueryMutations = [];
// Parse each file for URLs and query/mutation data
for (const file of files) {
try {
const content = readFileSync(file, "utf-8");
const urls = parseURLsFromFile(content, file, excludePatterns);
allUrls.push(...urls);
// Also parse for query/mutation data generation
try {
const queryMutations = parseQueryMutationFiles(content, file, excludePatterns);
allQueryMutations.push(...queryMutations);
}
catch (error) {
console.error(`MSW Mock Gen: Error parsing query/mutation file ${file}:`, error);
}
}
catch (error) {
console.error(`MSW Mock Gen: Error reading file ${file}:`, error);
}
}
// Generate mock data files first
const mockDataFiles = generateMockDataFiles(allQueryMutations, root);
// Generate MSW handlers with mock data
const handlers = generateMSWHandlers(allUrls, mockDataFiles);
try {
// Write query handlers
const queryHandlersFile = join(outputPath, "queryHandlers.generated.ts");
writeFileSync(queryHandlersFile, handlers.queryHandlers, "utf-8");
// Write mutation handlers
const mutationHandlersFile = join(outputPath, "mutationHandlers.generated.ts");
writeFileSync(mutationHandlersFile, handlers.mutationHandlers, "utf-8");
// Write index file
const indexFile = join(outputPath, `${outputFileName}.ts`);
writeFileSync(indexFile, handlers.indexFile, "utf-8");
log(`MSW Mock Gen: Generated handlers at ${outputPath} with ${allUrls.length} endpoints and ${mockDataFiles.length} mock data files`);
}
catch (error) {
console.error(`MSW Mock Gen: Error writing handlers files:`, error);
}
};
/**
* Generates MSW handlers for all configurations
* @param root - Project root directory
*/
const generateAllHandlers = async (root) => {
const startTime = Date.now();
log("MSW Mock Gen: Starting handler generation...");
for (const config of finalConfigs) {
await generateHandlers(root, config);
}
// Merge handlers if enabled
await mergeAllHandlers(root, finalConfigs);
const endTime = Date.now();
const duration = endTime - startTime;
log(`MSW Mock Gen: Handler generation complete (${duration}ms)`);
};
/**
* Generates MSW handlers for a specific configuration and merges all handlers
* @param root - Project root directory
* @param changedConfig - The configuration that needs to be regenerated
*/
const generateSingleConfigHandlers = async (root, changedConfig) => {
const startTime = Date.now();
log(`MSW Mock Gen: Regenerating handlers for config: ${changedConfig.watchFolder}`);
// Only regenerate the changed config
await generateHandlers(root, changedConfig);
// Always merge handlers after any config change
await mergeAllHandlers(root, finalConfigs);
const endTime = Date.now();
const duration = endTime - startTime;
log(`MSW Mock Gen: Handler regeneration complete (${duration}ms)`);
};
return {
name: "msw-mock-gen",
apply: "serve",
configResolved(config) {
projectRoot = config.root;
},
configureServer(server) {
// Only initialize once
if (isInitialized) {
return;
}
isInitialized = true;
log(`MSW Mock Gen: Watching ${finalConfigs.length} configuration(s)`);
for (const config of finalConfigs) {
const { watchFolder = "src/data/queries", outputFolder = "src/data/queries/mocks", } = config;
log(`MSW Mock Gen: Watching ${watchFolder} for changes`);
log(`MSW Mock Gen: Output will be written to ${outputFolder}`);
}
if (mergeHandlers) {
log(`MSW Mock Gen: Merged handlers will be written to ${topLevelOutputFolder}/${topLevelOutputFileName}.ts`);
}
// Only sterilize on initial startup, not on every file change
// This ensures .cache files are preserved between events
sterilize(projectRoot, finalConfigs, mergeHandlers, topLevelOutputFolder, topLevelOutputFileName, log);
// Generate initial handlers for all configs
generateAllHandlers(projectRoot).then(() => {
// Run format script after all generation is complete
if (formatScript) {
runFormatScript(projectRoot);
}
});
// Watch for file changes and deletions for each config
for (const config of finalConfigs) {
const { watchFolder = "src/data/queries", outputFolder = "src/data/queries/mocks", } = config;
const watchPath = join(projectRoot, watchFolder);
if (existsSync(watchPath)) {
server.watcher.add(watchPath);
const handleFileEvent = (file) => {
// Check if the changed file is within the watchFolder or its subdirectories
const normalizedFile = file.replace(/\\/g, "/"); // Normalize path separators
const normalizedWatchPath = watchPath.replace(/\\/g, "/");
// Skip changes to the output folder and generated files to prevent infinite loops
if (normalizedFile.startsWith(normalizedWatchPath) &&
!normalizedFile.includes(outputFolder) &&
!normalizedFile.includes(".generated.") &&
!normalizedFile.includes(".mocks.gen.")) {
log(`MSW Mock Gen: File changed: ${file}`);
generateSingleConfigHandlers(projectRoot, config).then(() => {
// Run format script after generation is complete
if (formatScript) {
runFormatScript(projectRoot);
}
});
}
};
server.watcher.on("change", (file) => handleFileEvent(file));
server.watcher.on("unlink", (file) => handleFileEvent(file));
server.watcher.on("add", (file) => handleFileEvent(file));
}
}
},
buildStart() {
log("MSW Mock Gen: Build started");
// Reset initialization flag for new builds
isInitialized = false;
// Sterilize output directories before starting
sterilize(projectRoot, finalConfigs, mergeHandlers, topLevelOutputFolder, topLevelOutputFileName, log);
// Note: Initial generation is handled in configureServer to avoid duplication
// Only sterilize here to ensure clean state for builds
},
};
}