• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pmcelhaney / counterfact / 23948016021

03 Apr 2026 01:34PM UTC coverage: 88.013% (+0.2%) from 87.817%
23948016021

Pull #1624

github

web-flow
Merge branch 'main' into copilot/fix-route-file-syntax-error
Pull Request #1624: Fix: syntax error in a route file no longer crashes the server

1740 of 1960 branches covered (88.78%)

Branch coverage included in aggregate %.

37 of 37 new or added lines in 1 file covered. (100.0%)

4 existing lines in 1 file now uncovered.

5294 of 6032 relevant lines covered (87.77%)

64.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.24
/src/server/module-loader.ts
1
import { once } from "node:events";
2✔
2
import { existsSync } from "node:fs";
2✔
3
import fs from "node:fs/promises";
2✔
4
import nodePath, { basename, dirname } from "node:path";
2✔
5

2✔
6
import { type FSWatcher, watch } from "chokidar";
2✔
7
import createDebug from "debug";
2✔
8

2✔
9
import { CHOKIDAR_OPTIONS } from "./constants.js";
2✔
10
import { type Context, ContextRegistry } from "./context-registry.js";
2✔
11
import { determineModuleKind } from "./determine-module-kind.js";
2✔
12
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
2✔
13
import type { MiddlewareFunction, Module, Registry } from "./registry.js";
2✔
14
import { uncachedImport } from "./uncached-import.js";
2✔
15

2✔
16
const { uncachedRequire } = await import("./uncached-require.cjs");
2✔
17

2✔
18
const debug = createDebug("counterfact:server:module-loader");
2✔
19

2✔
20
import {
2✔
21
  escapePathForWindows,
2✔
22
  unescapePathForWindows,
2✔
23
} from "../util/windows-escape.js";
2✔
24

2✔
25
interface ContextModule {
2✔
26
  Context?: Context;
2✔
27
}
2✔
28

2✔
29
function isContextModule(
18✔
30
  module: ContextModule | Module,
18✔
31
): module is ContextModule {
18✔
32
  return "Context" in module && typeof module.Context === "function";
18✔
33
}
18✔
34

2✔
35
function isMiddlewareModule(
4✔
36
  module: ContextModule | Module,
4✔
37
): module is ContextModule & { middleware: MiddlewareFunction } {
4✔
38
  return (
4✔
39
    "middleware" in module &&
4✔
40
    typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
4✔
41
      "function"
4✔
42
  );
4✔
43
}
4✔
44

2✔
45
export class ModuleLoader extends EventTarget {
2✔
46
  private readonly basePath: string;
2✔
47

32✔
48
  public readonly registry: Registry;
32✔
49

32✔
50
  private watcher: FSWatcher | undefined;
32✔
51

32✔
52
  private readonly contextRegistry: ContextRegistry;
32✔
53

32✔
54
  private readonly dependencyGraph = new ModuleDependencyGraph();
32✔
55

32✔
56
  private readonly uncachedImport: (moduleName: string) => Promise<unknown> =
32✔
57
    async function (moduleName: string) {
32✔
58
      throw new Error(`uncachedImport not set up; importing ${moduleName}`);
×
59
    };
×
60

2✔
61
  public constructor(
2✔
62
    basePath: string,
32✔
63
    registry: Registry,
32✔
64
    contextRegistry = new ContextRegistry(),
32✔
65
  ) {
32✔
66
    super();
32✔
67
    this.basePath = basePath.replaceAll("\\", "/");
32✔
68
    this.registry = registry;
32✔
69
    this.contextRegistry = contextRegistry;
32✔
70
  }
32✔
71

2✔
72
  public async watch(): Promise<void> {
2✔
73
    this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on(
6✔
74
      "all",
6✔
75

6✔
76
      (eventName: string, pathNameOriginal: string) => {
6✔
77
        const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
4✔
78

4✔
79
        if (
4✔
80
          !JS_EXTENSIONS.some((extension) =>
4✔
81
            pathNameOriginal.endsWith(`.${extension}`),
4✔
82
          )
4✔
83
        )
4✔
84
          return;
4!
85

4✔
86
        const pathName = pathNameOriginal.replaceAll("\\", "/");
4✔
87

4✔
88
        if (pathName.includes("$.context") && eventName === "add") {
4!
89
          process.stdout.write(
×
90
            `\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`,
×
91
          );
×
92

×
93
          return;
×
94
        }
×
95

4✔
96
        if (!["add", "change", "unlink"].includes(eventName)) {
4!
97
          return;
×
98
        }
×
99

4✔
100
        const parts = nodePath.parse(pathName.replace(this.basePath, ""));
4✔
101
        const url = unescapePathForWindows(
4✔
102
          `/${parts.dir}/${parts.name}`
4✔
103
            .replaceAll("\\", "/")
4✔
104
            .replaceAll(/\/+/gu, "/"),
4✔
105
        );
4✔
106

4✔
107
        if (eventName === "unlink") {
4✔
108
          this.registry.remove(url);
4✔
109
          this.dispatchEvent(new Event("remove"));
4✔
110
          return;
4✔
111
        }
4!
112

×
113
        const dependencies = this.dependencyGraph.dependentsOf(pathName);
×
114

×
115
        void this.loadEndpoint(pathName);
×
116

×
117
        for (const dependency of dependencies) {
×
118
          void this.loadEndpoint(dependency);
×
119
        }
×
120
      },
×
121
    );
6✔
122
    await once(this.watcher, "ready");
6✔
123
  }
6✔
124

2✔
125
  public async stopWatching(): Promise<void> {
2✔
126
    await this.watcher?.close();
8✔
127
  }
8✔
128

2✔
129
  public async load(directory = ""): Promise<void> {
2✔
130
    if (
42✔
131
      !existsSync(nodePath.join(this.basePath, directory).replaceAll("\\", "/"))
42✔
132
    ) {
42!
133
      throw new Error(`Directory does not exist ${this.basePath}`);
×
134
    }
×
135

42✔
136
    const files = await fs.readdir(
42✔
137
      nodePath.join(this.basePath, directory).replaceAll("\\", "/"),
42✔
138
      {
42✔
139
        withFileTypes: true,
42✔
140
      },
42✔
141
    );
42✔
142

42✔
143
    const imports = files.flatMap(async (file): Promise<void> => {
42✔
144
      const extension = file.name.split(".").at(-1);
84✔
145

84✔
146
      if (file.isDirectory()) {
84✔
147
        await this.load(
16✔
148
          nodePath.join(directory, file.name).replaceAll("\\", "/"),
16✔
149
        );
16✔
150

16✔
151
        return;
16✔
152
      }
16✔
153

68✔
154
      if (!["cjs", "cts", "js", "mjs", "mts", "ts"].includes(extension ?? "")) {
84✔
155
        return;
30✔
156
      }
30✔
157

38✔
158
      const fullPath = nodePath
38✔
159
        .join(this.basePath, directory, file.name)
38✔
160
        .replaceAll("\\", "/");
38✔
161

38✔
162
      await this.loadEndpoint(escapePathForWindows(fullPath));
38✔
163
    });
38✔
164

42✔
165
    await Promise.all(imports);
42✔
166
  }
42✔
167

2✔
168
  private async loadEndpoint(pathName: string) {
2✔
169
    debug("importing module: %s", pathName);
38✔
170

38✔
171
    const directory = dirname(pathName.slice(this.basePath.length)).replaceAll(
38✔
172
      "\\",
38✔
173
      "/",
38✔
174
    );
38✔
175

38✔
176
    const url = unescapePathForWindows(
38✔
177
      `/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
38✔
178
        .replaceAll("\\", "/")
38✔
179
        .replaceAll(/\/+/gu, "/"),
38✔
180
    );
38✔
181

38✔
182
    debug(`loading pathName from dependencyGraph: ${pathName}`);
38✔
183

38✔
184
    this.dependencyGraph.load(pathName);
38✔
185

38✔
186
    try {
38✔
187
      const doImport =
38✔
188
        (await determineModuleKind(pathName)) === "commonjs"
38✔
189
          ? uncachedRequire
2✔
190
          : uncachedImport;
36✔
191

38✔
192
      let importError: unknown;
38✔
193

38✔
194
      const endpoint = (await doImport(pathName).catch((error: unknown) => {
38✔
195
        importError = error;
4✔
196
      })) as ContextModule | Module;
4✔
197

38✔
198
      if (importError !== undefined) {
38✔
199
        const isSyntaxError =
4✔
200
          importError instanceof SyntaxError ||
4✔
201
          String(importError).startsWith("SyntaxError:");
2✔
202

4✔
203
        const displayPath = nodePath
4✔
204
          .relative(process.cwd(), unescapePathForWindows(pathName))
4✔
205
          .replaceAll("\\", "/");
4✔
206

4✔
207
        const message = isSyntaxError
4✔
208
          ? `There is a syntax error in the route file: ${displayPath}`
2✔
209
          : `There was an error loading the route file: ${displayPath}`;
4✔
210

4✔
211

4✔
212
        const errorResponse = () => ({
4✔
213
          body: message,
4✔
214
          status: 500,
4✔
215
        });
4✔
216

4✔
217
        this.registry.add(url, {
4✔
218
          DELETE: errorResponse,
4✔
219
          GET: errorResponse,
4✔
220
          HEAD: errorResponse,
4✔
221
          OPTIONS: errorResponse,
4✔
222
          PATCH: errorResponse,
4✔
223
          POST: errorResponse,
4✔
224
          PUT: errorResponse,
4✔
225
          TRACE: errorResponse,
4✔
226
        });
4✔
227

4✔
228
        this.dispatchEvent(new Event("add"));
4✔
229

4✔
230
        return;
4✔
231
      }
4✔
232

34✔
233
      if (!endpoint) {
38!
234
        return;
×
UNCOV
235
      }
✔
236

34✔
237
      this.dispatchEvent(new Event("add"));
34✔
238

34✔
239
      if (
34✔
240
        basename(pathName).startsWith("_.context.") &&
34✔
241
        isContextModule(endpoint)
18✔
242
      ) {
38✔
243
        const loadContext = (path: string) => this.contextRegistry.find(path);
18✔
244

18✔
245
        const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
18✔
246
        const readJson = async (relativePath: string): Promise<unknown> => {
18✔
247
          const absolutePath = nodePath.resolve(contextDir, relativePath);
4✔
248
          let content: string;
4✔
249
          try {
4✔
250
            content = await fs.readFile(absolutePath, "utf8");
4✔
251
          } catch {
4!
252
            throw new Error(
×
253
              `readJson: could not read file at "${absolutePath}" (resolved from "${relativePath}" relative to "${contextDir}")`,
×
254
            );
×
UNCOV
255
          }
×
256
          try {
4✔
257
            return JSON.parse(content) as unknown;
4✔
258
          } catch {
4!
259
            throw new Error(
×
260
              `readJson: file at "${absolutePath}" does not contain valid JSON`,
×
261
            );
×
UNCOV
262
          }
×
263
        };
4✔
264

18✔
265
        this.contextRegistry.update(
18✔
266
          directory,
18✔
267

18✔
268
          // @ts-expect-error TS says Context has no constructable signatures but that's not true?
18✔
269

18✔
270
          new endpoint.Context({
18✔
271
            loadContext,
18✔
272
            readJson,
18✔
273
          }),
18✔
274
        );
18✔
275
        return;
18✔
276
      }
18✔
277

16✔
278
      if (
16✔
279
        basename(pathName).startsWith("_.middleware.") &&
16✔
280
        isMiddlewareModule(endpoint)
4✔
281
      ) {
38✔
282
        this.registry.addMiddleware(
4✔
283
          url.slice(0, url.lastIndexOf("/")) || "/",
4✔
284
          endpoint.middleware,
4✔
285
        );
4✔
286
      }
4✔
287

16✔
288
      if (url === "/index") this.registry.add("/", endpoint as Module);
38✔
289

16✔
290
      debug(`adding "${url}" to registry`);
16✔
291
      this.registry.add(url, endpoint as Module);
16✔
292
    } catch (error: unknown) {
38!
293
      if (
×
294
        String(error) ===
×
295
        "SyntaxError: Identifier 'Context' has already been declared"
×
296
      ) {
×
297
        // Not sure why Node throws this error. It doesn't seem to matter.
×
298
        return;
×
299
      }
×
300

×
301
      process.stdout.write(`\nError loading ${pathName}:\n${String(error)}\n`);
×
302

×
303
      throw error;
×
UNCOV
304
    }
×
305
  }
38✔
306
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc