diff --git a/apps/oxfmt/src-js/cli/js_config.ts b/apps/oxfmt/src-js/cli/js_config.ts index 435c2d4500748..b81acdc15c328 100644 --- a/apps/oxfmt/src-js/cli/js_config.ts +++ b/apps/oxfmt/src-js/cli/js_config.ts @@ -1,5 +1,6 @@ import { basename as pathBasename } from "node:path"; import { pathToFileURL } from "node:url"; +import { getUnsupportedTypeScriptModuleLoadHintForError } from "./node_version"; const isObject = (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v); @@ -23,7 +24,13 @@ export async function loadJsConfig(path: string): Promise { const fileUrl = pathToFileURL(path); fileUrl.searchParams.set("cache", Date.now().toString()); - const { default: config } = await import(fileUrl.href); + let config: unknown; + try { + ({ default: config } = await import(fileUrl.href)); + } catch (err) { + const unsupportedNodeHint = getUnsupportedTypeScriptModuleLoadHintForError(err, path); + throw unsupportedNodeHint ?? err; + } if (config === undefined) throw new Error("Configuration file has no default export."); diff --git a/apps/oxfmt/src-js/cli/node_version.ts b/apps/oxfmt/src-js/cli/node_version.ts new file mode 100644 index 0000000000000..955aefceaaa6e --- /dev/null +++ b/apps/oxfmt/src-js/cli/node_version.ts @@ -0,0 +1,49 @@ +import { extname as pathExtname } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const NODE_TYPESCRIPT_SUPPORT_RANGE = "^20.19.0 || >=22.12.0"; + +const TS_MODULE_EXTENSIONS = new Set([".ts", ".mts", ".cts"]); + +function normalizeModuleSpecifierPath(specifier: string): string { + if (!specifier.startsWith("file:")) return specifier; + + try { + return fileURLToPath(specifier); + } catch { + return specifier; + } +} + +export function isTypeScriptModuleSpecifier(specifier: string): boolean { + const ext = pathExtname(normalizeModuleSpecifierPath(specifier)).toLowerCase(); + return TS_MODULE_EXTENSIONS.has(ext); +} + +function isUnknownFileExtensionError(err: unknown): boolean { + if ((err as { code?: unknown })?.code === "ERR_UNKNOWN_FILE_EXTENSION") return true; + + const message = (err as { message?: unknown })?.message; + return typeof message === "string" && /unknown(?: or unsupported)? file extension/i.test(message); +} + +function getErrorSummary(err: unknown): string { + if (err instanceof Error) return String(err); + + const message = (err as { message?: unknown })?.message; + if (typeof message === "string" && message !== "") return message; + + return "Unknown error"; +} + +export function getUnsupportedTypeScriptModuleLoadHintForError( + err: unknown, + specifier: string, + nodeVersion: string = process.version, +): string | null { + if (!isTypeScriptModuleSpecifier(specifier) || !isUnknownFileExtensionError(err)) { + return null; + } + + return `${getErrorSummary(err)}\n\nTypeScript config files require Node.js ${NODE_TYPESCRIPT_SUPPORT_RANGE}.\nDetected Node.js ${nodeVersion}.\nPlease upgrade Node.js or use a JSON config file instead.`; +} diff --git a/apps/oxfmt/test/cli/js_config/node_version.test.ts b/apps/oxfmt/test/cli/js_config/node_version.test.ts new file mode 100644 index 0000000000000..b387576d37fcd --- /dev/null +++ b/apps/oxfmt/test/cli/js_config/node_version.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + NODE_TYPESCRIPT_SUPPORT_RANGE, + getUnsupportedTypeScriptModuleLoadHintForError, + isTypeScriptModuleSpecifier, +} from "../../../src-js/cli/node_version"; + +describe("node_version", () => { + it("detects supported TypeScript config specifiers", () => { + expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.ts")).toBe(true); + expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.mts")).toBe(true); + expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.cts")).toBe(true); + expect(isTypeScriptModuleSpecifier("file:///tmp/oxfmt.config.ts")).toBe(true); + expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.js")).toBe(false); + }); + + it("adds a node version hint for unsupported TypeScript module loading", () => { + const err = new TypeError( + 'Unknown file extension ".ts" for /tmp/oxfmt.config.ts', + ) as TypeError & { + code?: string; + }; + err.code = "ERR_UNKNOWN_FILE_EXTENSION"; + + expect( + getUnsupportedTypeScriptModuleLoadHintForError(err, "/tmp/oxfmt.config.ts", "v22.11.0"), + ).toBe( + `TypeError: Unknown file extension ".ts" for /tmp/oxfmt.config.ts\n\nTypeScript config files require Node.js ${NODE_TYPESCRIPT_SUPPORT_RANGE}.\nDetected Node.js v22.11.0.\nPlease upgrade Node.js or use a JSON config file instead.`, + ); + }); + + it("does not add the hint for non-TypeScript specifiers or unrelated errors", () => { + const err = new Error("Cannot find package"); + expect(getUnsupportedTypeScriptModuleLoadHintForError(err, "/tmp/oxfmt.config.ts")).toBeNull(); + + const unknownExtension = new TypeError('Unknown file extension ".ts"'); + expect( + getUnsupportedTypeScriptModuleLoadHintForError(unknownExtension, "/tmp/oxfmt.config.js"), + ).toBeNull(); + }); +});