Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/renovate-385c5da.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'counterfact': patch
---

Updated dependency `typescript` to `7.0.2`.
2 changes: 1 addition & 1 deletion .changeset/renovate-8bc1dd8.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'counterfact': patch
---

Updated dependency `astro` to `7.0.7`.
Updated dependency `astro` to `7.1.1`.
2 changes: 1 addition & 1 deletion .changeset/renovate-9aff90f.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'counterfact': patch
---

Updated dependency `astro` to `7.1.0`.
Updated dependency `astro` to `7.1.1`.
2 changes: 1 addition & 1 deletion .changeset/renovate-d5034f6.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'counterfact': patch
---

Updated dependency `posthog-node` to `5.41.0`.
Updated dependency `posthog-node` to `5.45.2`.
33 changes: 33 additions & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
"use strict";

const fs = require("fs");
const Module = require("module");
const path = require("path");

function resolveCompatibleTypeScriptPath() {
try {
// precinct still installs a TypeScript 6 copy that matches the current
// typescript-eslint peer range, so prefer that compiler for parser internals
// until the eslint stack natively supports TypeScript 7.
return require.resolve("typescript", {
paths: [path.join(__dirname, "node_modules", "precinct", "node_modules")],
});
} catch {
return undefined;
}
}

const compatibleTypeScriptPath = resolveCompatibleTypeScriptPath();
const originalResolveFilename = Module._resolveFilename;

Module._resolveFilename = function resolveFilename(request, parent, ...rest) {
if (
request === "typescript" &&
compatibleTypeScriptPath !== undefined &&
fs.existsSync(compatibleTypeScriptPath) &&

Check warning on line 27 in eslint.config.cjs

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found existsSync from package "fs" with non literal argument at index 0
["/@typescript-eslint/", "/ts-api-utils/"].some((segment) =>
parent?.filename?.includes(segment.replaceAll("/", path.sep)),
)
) {
return compatibleTypeScriptPath;
}

return originalResolveFilename.call(this, request, parent, ...rest);
};

const js = require("@eslint/js");
const prettierPlugin = require("eslint-plugin-prettier");
const typescriptParser = require("@typescript-eslint/parser");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
"prettier": "3.8.5",
"recast": "0.23.12",
"tsx": "4.23.1",
"typescript": "6.0.3"
"typescript": "7.0.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": {
Expand Down
77 changes: 72 additions & 5 deletions src/server/module-dependency-graph.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,91 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";

import createDebug from "debug";
import precinct from "precinct";
import { parse } from "recast";
import typescriptParser from "recast/parsers/typescript.js";

const debug = createDebug("counterfact:server:module-dependency-graph");

type AstNode = {
type?: unknown;
[key: string]: unknown;
};

function isNode(value: unknown): value is AstNode {
return typeof value === "object" && value !== null;
}

function extractStringLiteral(value: unknown): string | undefined {
return isNode(value) && typeof value.value === "string"
? value.value
: undefined;
}

function dependencyIn(node: AstNode): string | undefined {
if (
node.type === "ImportDeclaration" ||
node.type === "ExportAllDeclaration" ||
node.type === "ExportNamedDeclaration" ||
node.type === "ImportExpression"
) {
return extractStringLiteral(node.source);
}

if (
node.type === "CallExpression" &&
isNode(node.callee) &&
node.callee.type === "Identifier" &&
node.callee.name === "require" &&
Array.isArray(node.arguments)
) {
return extractStringLiteral(node.arguments[0]);
}
}

function findDependencies(value: unknown, dependencies: Set<string>) {
if (Array.isArray(value)) {
for (const entry of value) {
findDependencies(entry, dependencies);
}
return;
}

if (!isNode(value)) {
return;
}

const dependency = dependencyIn(value);
if (dependency !== undefined) {
dependencies.add(dependency);
}

for (const entry of Object.values(value)) {
findDependencies(entry, dependencies);
}
}

function dependenciesIn(source: string): string[] {
const dependencies = new Set<string>();

findDependencies(parse(source, { parser: typescriptParser }), dependencies);

return Array.from(dependencies);
}

/**
* Tracks which route files depend on shared modules so that when a shared
* module changes, all dependent route files can be reloaded.
*
* Dependency edges are extracted using [precinct](https://npm.im/precinct)'s
* static analysis and are stored as a reverse map (`dependency → Set<dependent
* files>`).
* Dependency edges are extracted from the parsed module syntax and are stored
* as a reverse map (`dependency → Set<dependent files>`).
*/
export class ModuleDependencyGraph {
private readonly dependents = new Map<string, Set<string>>();

private loadDependencies(path: string) {
try {
return precinct.paperwork(path);
return dependenciesIn(readFileSync(path, "utf8"));
} catch (error) {
debug("could not load dependencies for %s: %o", path, error);
return [];
Expand Down
20 changes: 15 additions & 5 deletions src/server/module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import nodePath, { basename } from "node:path";

import { type FSWatcher, watch } from "chokidar";
import createDebug from "debug";
import { parse } from "recast";
import typescriptParser from "recast/parsers/typescript.js";

import { CHOKIDAR_OPTIONS } from "./constants.js";
import { ContextRegistry } from "./context-registry.js";
Expand All @@ -31,6 +33,10 @@ const { uncachedRequire } = await import("./uncached-require.cjs");

const debug = createDebug("counterfact:server:module-loader");

async function assertModuleSyntax(pathName: string) {
parse(await fs.readFile(pathName, "utf8"), { parser: typescriptParser });
}

/**
* Watches the compiled routes directory and dynamically loads/reloads route
* modules, context files, and middleware as files are added, changed, or
Expand Down Expand Up @@ -292,10 +298,16 @@ export class ModuleLoader extends EventTarget {
: uncachedImport;

let importError: unknown;
let endpoint: ContextModule | Module | undefined;

const endpoint = (await doImport(pathName).catch((error: unknown) => {
try {
if (doImport === uncachedImport) {
await assertModuleSyntax(pathName);
}
endpoint = (await doImport(pathName)) as ContextModule | Module;
} catch (error) {
importError = error;
})) as ContextModule | Module;
}

if (importError !== undefined) {
const isSyntaxError =
Expand All @@ -308,9 +320,7 @@ export class ModuleLoader extends EventTarget {
);

if (this.isContextFile(pathName)) {
const warning = isSyntaxError
? `Warning: There is a syntax error in the context file: ${displayPath}`
: `Warning: There was an error loading the context file: ${displayPath}`;
const warning = `Warning: There was an error loading the context file: ${displayPath}`;

process.stdout.write(`\n${warning}\n`);

Expand Down
73 changes: 45 additions & 28 deletions src/server/transpiler.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
// Stryker disable all

import { execFile as execFileCallback } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
/* eslint-disable security/detect-non-literal-fs-filename -- transpiler consumes watched source files and writes paired outputs under configured directories. */

import { type FSWatcher, watch as chokidarWatch } from "chokidar";
import createDebug from "debug";
import ts from "typescript";

import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
import { CHOKIDAR_OPTIONS } from "./constants.js";
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";

const debug = createDebug("counterfact:server:transpiler");
const execFile = promisify(execFileCallback);
const typescriptCompilerPath = fileURLToPath(
new URL("../../node_modules/typescript/lib/tsc.js", import.meta.url),
);

/**
* Watches TypeScript source files in `sourcePath` and compiles them to
* JavaScript in `destinationPath` using the TypeScript compiler API.
* JavaScript in `destinationPath` using the TypeScript CLI.
*
* Used when the runtime cannot execute TypeScript natively (i.e. Node.js
* without the `--experimental-strip-types` flag). Each file is compiled
Expand Down Expand Up @@ -121,50 +127,61 @@ export class Transpiler extends EventTarget {
await this.watcher?.close();
}

private compiledDestinationPath(sourcePath: string) {
return pathJoin(
sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", ".js"),
);
}

private async transpileFile(
eventName: string,
sourcePath: string,
destinationPath: string,
): Promise<void> {
ensureDirectoryExists(destinationPath);
const compiledDestinationPath = this.compiledDestinationPath(sourcePath);

const source = await fs.readFile(sourcePath, "utf8");

const transpileOutput = ts.transpileModule(source, {
compilerOptions: {
module:
ts.ModuleKind[
this.moduleKind.toLowerCase() === "module" ? "ES2022" : "CommonJS"
],

target: ts.ScriptTarget.ES2015,
},
reportDiagnostics: true,
});
try {
await execFile(process.execPath, [
typescriptCompilerPath,
"--ignoreConfig",
"--module",
this.moduleKind.toLowerCase() === "module" ? "ES2022" : "CommonJS",
"--target",
"ES2015",
"--outDir",
this.destinationPath,
"--rootDir",
this.sourcePath,
// TypeScript 7's CLI supports --noCheck, which preserves the prior
// transpileModule behavior of skipping type-checking during cache builds.
"--noCheck",
sourcePath,
]);
} catch (error) {
debug("error transpiling %s after %s: %o", sourcePath, eventName, error);
this.dispatchEvent(new Event("error"));

if (transpileOutput.diagnostics?.length) {
for (const diagnostic of transpileOutput.diagnostics) {
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n",
);
debug("TypeScript diagnostic in %s: %s", sourcePath, message);
}
throw new Error(`could not transpile ${sourcePath}`, { cause: error });
}

const result: string = transpileOutput.outputText;

const fullDestination = pathJoin(
sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension),
);

const resultWithTransformedFileExtensions =
convertFileExtensionsToCjs(result);
const resultWithTransformedFileExtensions = convertFileExtensionsToCjs(
await fs.readFile(compiledDestinationPath, "utf8"),
);

try {
await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
if (compiledDestinationPath !== fullDestination) {
await fs.rm(compiledDestinationPath);
}
} catch (error) {
debug(
"error writing transpiled output to %s: %o",
Expand All @@ -173,7 +190,7 @@ export class Transpiler extends EventTarget {
);
this.dispatchEvent(new Event("error"));

throw new Error("could not transpile", { cause: error });
throw new Error(`could not transpile ${sourcePath}`, { cause: error });
}

this.dispatchEvent(new Event("write"));
Expand Down
Loading
Loading