UNPKG

counterfact

Version:

a library for building a fake REST API for testing

137 lines (136 loc) 6.04 kB
/* eslint-disable n/no-sync */ import { once } from "node:events"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import nodePath, { basename, dirname } from "node:path"; import { watch } from "chokidar"; import createDebug from "debug"; import { CHOKIDAR_OPTIONS } from "./constants.js"; import { ContextRegistry } from "./context-registry.js"; import { determineModuleKind } from "./determine-module-kind.js"; import { ModuleDependencyGraph } from "./module-dependency-graph.js"; import { uncachedImport } from "./uncached-import.js"; const { uncachedRequire } = await import("./uncached-require.cjs"); const debug = createDebug("counterfact:server:module-loader"); function isContextModule(module) { return "Context" in module && typeof module.Context === "function"; } function isMiddlewareModule(module) { return ("middleware" in module && typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value === "function"); } export class ModuleLoader extends EventTarget { basePath; registry; watcher; contextRegistry; dependencyGraph = new ModuleDependencyGraph(); uncachedImport = async function (moduleName) { throw new Error(`uncachedImport not set up; importing ${moduleName}`); }; constructor(basePath, registry, contextRegistry = new ContextRegistry()) { super(); this.basePath = basePath.replaceAll("\\", "/"); this.registry = registry; this.contextRegistry = contextRegistry; } async watch() { this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => { const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"]; if (!JS_EXTENSIONS.some((extension) => pathNameOriginal.endsWith(`.${extension}`))) return; const pathName = pathNameOriginal.replaceAll("\\", "/"); if (pathName.includes("$.context") && eventName === "add") { process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/context-change.md\n\n\n`); return; } if (!["add", "change", "unlink"].includes(eventName)) { return; } const parts = nodePath.parse(pathName.replace(this.basePath, "")); const url = `/${parts.dir}/${parts.name}` .replaceAll("\\", "/") .replaceAll(/\/+/gu, "/"); if (eventName === "unlink") { this.registry.remove(url); this.dispatchEvent(new Event("remove")); } const dependencies = this.dependencyGraph.dependentsOf(pathName); void this.loadEndpoint(pathName); for (const dependency of dependencies) { void this.loadEndpoint(dependency); } }); await once(this.watcher, "ready"); } async stopWatching() { await this.watcher?.close(); } async load(directory = "") { if (!existsSync(nodePath.join(this.basePath, directory).replaceAll("\\", "/"))) { throw new Error(`Directory does not exist ${this.basePath}`); } const files = await fs.readdir(nodePath.join(this.basePath, directory).replaceAll("\\", "/"), { withFileTypes: true, }); const imports = files.flatMap(async (file) => { const extension = file.name.split(".").at(-1); if (file.isDirectory()) { await this.load(nodePath.join(directory, file.name).replaceAll("\\", "/")); return; } if (!["cjs", "cts", "js", "mjs", "mts", "ts"].includes(extension ?? "")) { return; } const fullPath = nodePath .join(this.basePath, directory, file.name) .replaceAll("\\", "/"); await this.loadEndpoint(fullPath); }); await Promise.all(imports); } async loadEndpoint(pathName) { debug("importing module: %s", pathName); const directory = dirname(pathName.slice(this.basePath.length)).replaceAll("\\", "/"); const url = `/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}` .replaceAll("\\", "/") .replaceAll(/\/+/gu, "/"); this.dependencyGraph.load(pathName); try { const doImport = (await determineModuleKind(pathName)) === "commonjs" ? uncachedRequire : uncachedImport; const endpoint = (await doImport(pathName).catch((err) => { console.log("ERROR"); })); this.dispatchEvent(new Event("add")); if (basename(pathName).startsWith("_.context.") && isContextModule(endpoint)) { const loadContext = (path) => this.contextRegistry.find(path); this.contextRegistry.update(directory, // @ts-expect-error TS says Context has no constructable signatures but that's not true? new endpoint.Context({ loadContext, })); return; } if (basename(pathName).startsWith("_.middleware.") && isMiddlewareModule(endpoint)) { this.registry.addMiddleware(url.slice(0, url.lastIndexOf("/")) || "/", endpoint.middleware); } if (url === "/index") this.registry.add("/", endpoint); this.registry.add(url, endpoint); } catch (error) { if (String(error) === "SyntaxError: Identifier 'Context' has already been declared") { // Not sure why Node throws this error. It doesn't seem to matter. return; } process.stdout.write(`\nError loading ${pathName}:\n${String(error)}\n`); throw error; } } }