From a6c4b2f9cd643f354dcf2ac6f8b01ccf87ed1d20 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Wed, 2 Sep 2026 09:11:44 +0000 Subject: [PATCH] fix(elements-react): add .mjs extensions to unbundled client ESM imports Node ESM cannot resolve extensionless relative imports emitted by the unbundled client build. Rewrite those specifiers to .mjs after tsup so @ory/elements-react/client loads under Node/Next ESM. Fixes #573 --- .../rewrite-esm-relative-imports.spec.ts | 137 ++++++++++++++++++ .../client/rewrite-esm-relative-imports.ts | 35 +++++ packages/elements-react/tsup.config.ts | 11 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 packages/elements-react/src/client/rewrite-esm-relative-imports.spec.ts create mode 100644 packages/elements-react/src/client/rewrite-esm-relative-imports.ts diff --git a/packages/elements-react/src/client/rewrite-esm-relative-imports.spec.ts b/packages/elements-react/src/client/rewrite-esm-relative-imports.spec.ts new file mode 100644 index 000000000..0f3d42894 --- /dev/null +++ b/packages/elements-react/src/client/rewrite-esm-relative-imports.spec.ts @@ -0,0 +1,137 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process" +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { rewriteEsmRelativeImports } from "./rewrite-esm-relative-imports" + +function nodeRun(file: string) { + return spawnSync(process.execPath, [file], { + encoding: "utf8", + timeout: 5000, + }) +} + +describe("rewriteEsmRelativeImports", () => { + it("adds .mjs to the extensionless frontendClient import from issue #573", () => { + const input = `import { frontendClient } from "./frontendClient";\n` + expect(rewriteEsmRelativeImports(input)).toBe( + `import { frontendClient } from "./frontendClient.mjs";\n`, + ) + }) + + it("adds .mjs to session-provider re-exports from the ESM client entry", () => { + const input = `import { + SessionProvider +} from "./session-provider"; +import { useSession } from "./useSession"; +` + const output = rewriteEsmRelativeImports(input) + expect(output).toContain(`from "./session-provider.mjs"`) + expect(output).toContain(`from "./useSession.mjs"`) + }) + + it("does not double-append extensions", () => { + const input = `import { frontendClient } from "./frontendClient.mjs";\n` + expect(rewriteEsmRelativeImports(input)).toBe(input) + }) + + it("leaves package specifiers alone", () => { + const input = `import { Session } from "@ory/client-fetch";\n` + expect(rewriteEsmRelativeImports(input)).toBe(input) + }) +}) + +describe("Node ESM resolution (issue #573)", () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "ory-elements-esm-")) + writeFileSync( + path.join(dir, "frontendClient.mjs"), + "export function frontendClient() { return 1 }\n", + ) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it("throws ERR_MODULE_NOT_FOUND for extensionless relative imports", () => { + writeFileSync( + path.join(dir, "index.mjs"), + `import { frontendClient } from "./frontendClient";\nconsole.log(frontendClient())\n`, + ) + const result = nodeRun(path.join(dir, "index.mjs")) + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/ERR_MODULE_NOT_FOUND/) + expect(result.stderr).toMatch(/frontendClient/) + }) + + it("resolves after rewriteEsmRelativeImports adds .mjs", () => { + const broken = `import { frontendClient } from "./frontendClient";\nconsole.log(frontendClient())\n` + writeFileSync( + path.join(dir, "index.mjs"), + rewriteEsmRelativeImports(broken), + ) + const result = nodeRun(path.join(dir, "index.mjs")) + expect(result.status).toBe(0) + expect(result.stderr).not.toMatch(/ERR_MODULE_NOT_FOUND/) + expect(result.stdout).toMatch(/1/) + }) +}) + +describe("dist/client ESM build", () => { + const distClient = path.join(__dirname, "../../dist/client") + + function relativeSpecifiers(source: string): string[] { + const specs: string[] = [] + const re = /\b(?:from\s+|import\s*\(\s*)(['"])(\.[^'"]+)\1/g + let match: RegExpExecArray | null + while ((match = re.exec(source))) { + specs.push(match[2]) + } + return specs + } + + it("emits relative imports with .mjs extensions so Node ESM can resolve them", () => { + const files = readdirSync(distClient).filter( + (name) => name.endsWith(".mjs") && !name.endsWith(".map"), + ) + expect(files).toEqual( + expect.arrayContaining([ + "index.mjs", + "session-provider.mjs", + "frontendClient.mjs", + ]), + ) + + const missingExtension: string[] = [] + const missingFile: string[] = [] + + for (const file of files) { + const source = readFileSync(path.join(distClient, file), "utf8") + for (const spec of relativeSpecifiers(source)) { + if (!path.extname(spec)) { + missingExtension.push(`${file} imports ${spec}`) + continue + } + const resolved = path.resolve(distClient, spec) + if (!files.includes(path.basename(resolved))) { + missingFile.push(`${file} imports ${spec}`) + } + } + } + + expect(missingExtension).toEqual([]) + expect(missingFile).toEqual([]) + }) +}) diff --git a/packages/elements-react/src/client/rewrite-esm-relative-imports.ts b/packages/elements-react/src/client/rewrite-esm-relative-imports.ts new file mode 100644 index 000000000..d931e0303 --- /dev/null +++ b/packages/elements-react/src/client/rewrite-esm-relative-imports.ts @@ -0,0 +1,35 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { readdirSync, readFileSync, writeFileSync } from "node:fs" +import path from "node:path" + +/** + * Node ESM requires file extensions on relative specifiers. tsup's unbundled + * client build emits `from "./frontendClient"`; rewrite those to `.mjs`. + */ +export function rewriteEsmRelativeImports(source: string): string { + return source.replace( + /\b(from\s+|import\s*\(\s*)(['"])(\.[^'"]+)\2/g, + (full, prefix: string, quote: string, spec: string) => { + if (path.extname(spec)) { + return full + } + return `${prefix}${quote}${spec}.mjs${quote}` + }, + ) +} + +export function rewriteEsmRelativeImportsInDir(dir: string): void { + for (const name of readdirSync(dir)) { + if (!name.endsWith(".mjs")) { + continue + } + const file = path.join(dir, name) + const source = readFileSync(file, "utf8") + const next = rewriteEsmRelativeImports(source) + if (next !== source) { + writeFileSync(file, next) + } + } +} diff --git a/packages/elements-react/tsup.config.ts b/packages/elements-react/tsup.config.ts index 7d78a25e9..d9bf1231f 100644 --- a/packages/elements-react/tsup.config.ts +++ b/packages/elements-react/tsup.config.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import svgr from "esbuild-plugin-svgr" +import path from "node:path" import { defineConfig, type Options } from "tsup" +import { rewriteEsmRelativeImportsInDir } from "./src/client/rewrite-esm-relative-imports" const baseConfig: Options = { dts: true, @@ -34,8 +36,15 @@ export default defineConfig([ sourcemap: true, bundle: false, format: ["cjs", "esm"], - entry: ["src/client/**/*.{ts,tsx}", "!src/**/*.spec.{tsx,ts}"], + entry: [ + "src/client/**/*.{ts,tsx}", + "!src/**/*.spec.{tsx,ts}", + "!src/client/rewrite-esm-relative-imports.ts", + ], outDir: "dist/client", + onSuccess: () => { + rewriteEsmRelativeImportsInDir(path.resolve("dist/client")) + }, }, { ...baseConfig,