From 47b7fe92db89b5745e9b46e00d3e73c2d24137a2 Mon Sep 17 00:00:00 2001 From: Ogrodev Date: Sat, 30 May 2026 19:48:16 -0300 Subject: [PATCH] Fix tyndale-next config export --- .github/workflows/portability.yml | 9 +- packages/tyndale-next/package.json | 5 +- packages/tyndale-next/src/config.cjs | 168 ++++++++++++++++++++ packages/tyndale-next/src/config.ts | 134 +++++++++++++++- packages/tyndale-next/tests/config.test.ts | 42 ++++- packages/tyndale-next/tests/exports.test.ts | 129 +++++++++++++++ tests/e2e/fixture/next.config.mjs | 15 +- 7 files changed, 473 insertions(+), 29 deletions(-) create mode 100644 packages/tyndale-next/src/config.cjs diff --git a/.github/workflows/portability.yml b/.github/workflows/portability.yml index e7694be..1bf9f5a 100644 --- a/.github/workflows/portability.yml +++ b/.github/workflows/portability.yml @@ -14,8 +14,9 @@ name: Portability # Catches RSC boundary, SSR, and middleware bugs. # # Layer 1 is cheap and gates everything else. Layer 2 runs on a small matrix. -# Layer 3 runs on Linux only — Next.js runtime behaviour is not OS-sensitive, -# and Playwright+browsers on Windows/macOS add cost for little signal. +# Layer 3 runs on Linux only and current LTS Node lines — Next.js runtime +# behaviour is not OS-sensitive, and Playwright/browser installation on the +# moving current Node line has been flaky enough to hide product regressions. on: push: @@ -84,7 +85,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['20', '22', '24'] + node: ['20', '22'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -104,7 +105,7 @@ jobs: run: bun run build:packages - name: Install Playwright browser (chromium only) - run: bunx playwright install --with-deps chromium + run: bunx playwright install --with-deps --only-shell chromium - name: Run Next.js integration E2E run: bun run test:playwright diff --git a/packages/tyndale-next/package.json b/packages/tyndale-next/package.json index 8080e7a..e715bb9 100644 --- a/packages/tyndale-next/package.json +++ b/packages/tyndale-next/package.json @@ -26,7 +26,8 @@ }, "./config": { "types": "./dist/config.d.ts", - "import": "./dist/config.js" + "import": "./dist/config.js", + "require": "./dist/config.cjs" }, "./middleware": { "types": "./dist/middleware.d.ts", @@ -37,7 +38,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc && bun -e \"await Bun.write('dist/config.cjs', Bun.file('src/config.cjs'))\"", "test": "bun test" }, "keywords": [ diff --git a/packages/tyndale-next/src/config.cjs b/packages/tyndale-next/src/config.cjs new file mode 100644 index 0000000..ad33f31 --- /dev/null +++ b/packages/tyndale-next/src/config.cjs @@ -0,0 +1,168 @@ +// packages/tyndale-next/src/config.cjs +const { createRequire } = require('node:module'); +const fs = require('node:fs'); +const path = require('node:path'); + +const requireFromTyndaleNext = createRequire(__filename); + +/** The cookie name used by Tyndale middleware for locale persistence. */ +const TYNDALE_COOKIE_NAME = 'TYNDALE_LOCALE'; + +function readTyndaleConfig() { + const configPath = path.resolve(process.cwd(), 'tyndale.config.json'); + + let raw; + try { + raw = fs.readFileSync(configPath, 'utf-8'); + } catch { + throw new Error( + `tyndale.config.json not found at ${configPath}. Run "tyndale init" to create one.`, + ); + } + + try { + return JSON.parse(raw); + } catch { + throw new Error( + `tyndale.config.json at ${configPath} contains invalid JSON. Fix the syntax and rebuild.`, + ); + } +} + +function selectExportPath(value) { + if (typeof value === 'string') return value; + + if (Array.isArray(value)) { + for (const entry of value) { + const selected = selectExportPath(entry); + if (selected) return selected; + } + return undefined; + } + + if (!value || typeof value !== 'object') return undefined; + + for (const condition of ['import', 'node', 'default', 'require', 'bun']) { + const selected = selectExportPath(value[condition]); + if (selected) return selected; + } + + return undefined; +} + +function selectPackageExport(packageJson, exportKey) { + const exportsField = packageJson.exports; + + if (exportKey !== '.') { + return exportsField && + typeof exportsField === 'object' && + !Array.isArray(exportsField) + ? selectExportPath(exportsField[exportKey]) + : undefined; + } + + const rootExport = + exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField) + ? Object.prototype.hasOwnProperty.call(exportsField, '.') + ? exportsField['.'] + : exportsField + : exportsField; + + return ( + selectExportPath(rootExport) ?? + (typeof packageJson.module === 'string' ? packageJson.module : undefined) ?? + (typeof packageJson.main === 'string' ? packageJson.main : undefined) ?? + 'index.js' + ); +} + +function resolvePackageExport(packageName, exportKey) { + const searchPaths = requireFromTyndaleNext.resolve.paths(packageName) ?? []; + + for (const nodeModulesPath of searchPaths) { + const packageDir = path.join(nodeModulesPath, packageName); + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) continue; + + let packageJson; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + } catch { + throw new Error(`${packageName} package.json at ${packageJsonPath} is invalid JSON.`); + } + + const selectedExport = selectPackageExport(packageJson, exportKey); + if (!selectedExport) { + throw new Error( + `Unable to resolve ${packageName} ${exportKey} export from ${packageJsonPath}.`, + ); + } + + const entryPath = path.resolve(packageDir, selectedExport); + return fs.existsSync(entryPath) ? fs.realpathSync(entryPath) : entryPath; + } + + throw new Error( + `Unable to resolve ${packageName}. Install ${packageName} alongside tyndale-next.`, + ); +} + +function toTurbopackAliasPath(resolvedPath) { + const relativePath = path + .relative(process.cwd(), resolvedPath) + .split(path.sep) + .join('/'); + + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +} + +function withTyndaleConfig(nextConfig) { + const tyndaleConfig = readTyndaleConfig(); + + // Resolve to the exact physical paths so server and client bundles share the + // same tyndale-react instance (prevents duplicate React contexts). Webpack + // prefix-matches bare alias keys, so its alias is exact-only; Turbopack gets + // explicit aliases for each public package subpath we use. + const tyndaleReactPath = resolvePackageExport('tyndale-react', '.'); + const tyndaleReactServerPath = resolvePackageExport('tyndale-react', './server'); + const tyndaleReactTurbopackPath = toTurbopackAliasPath(tyndaleReactPath); + const tyndaleReactServerTurbopackPath = toTurbopackAliasPath( + tyndaleReactServerPath, + ); + + return { + ...nextConfig, + env: { + ...nextConfig.env, + TYNDALE_DEFAULT_LOCALE: tyndaleConfig.defaultLocale, + TYNDALE_LOCALES: JSON.stringify(tyndaleConfig.locales), + TYNDALE_COOKIE_NAME, + TYNDALE_LOCALE_ALIASES: JSON.stringify( + tyndaleConfig.localeAliases ?? {}, + ), + TYNDALE_OUTPUT: tyndaleConfig.output, + }, + turbopack: { + ...nextConfig.turbopack, + resolveAlias: { + ...nextConfig.turbopack?.resolveAlias, + 'tyndale-react': tyndaleReactTurbopackPath, + 'tyndale-react/server': tyndaleReactServerTurbopackPath, + }, + }, + webpack: (config, options) => { + // Chain with any existing webpack function from the user's config. + const resolved = nextConfig.webpack + ? nextConfig.webpack(config, options) + : config; + delete resolved.resolve.alias['tyndale-react']; + resolved.resolve.alias['tyndale-react$'] = tyndaleReactPath; + return resolved; + }, + }; +} + +module.exports = { + TYNDALE_COOKIE_NAME, + withTyndaleConfig, +}; diff --git a/packages/tyndale-next/src/config.ts b/packages/tyndale-next/src/config.ts index b0fc588..05e4381 100644 --- a/packages/tyndale-next/src/config.ts +++ b/packages/tyndale-next/src/config.ts @@ -1,7 +1,10 @@ // packages/tyndale-next/src/config.ts +import { createRequire } from 'node:module'; import * as fs from 'node:fs'; import * as path from 'node:path'; +const requireFromTyndaleNext = createRequire(import.meta.url); + /** The cookie name used by Tyndale middleware for locale persistence. */ export const TYNDALE_COOKIE_NAME = 'TYNDALE_LOCALE'; @@ -22,12 +25,29 @@ interface WebpackConfig { type WebpackOptions = Record; +type TurbopackResolveAlias = Record< + string, + string | string[] | Record +>; + +interface TurbopackConfig { + resolveAlias?: TurbopackResolveAlias; + [key: string]: unknown; +} + interface NextConfig { env?: Record; + turbopack?: TurbopackConfig; webpack?: (config: WebpackConfig, options: WebpackOptions) => WebpackConfig; [key: string]: unknown; } +interface PackageJson { + exports?: unknown; + main?: unknown; + module?: unknown; +} + /** * Reads tyndale.config.json from the project root (cwd). * Throws at build time with a clear message if not found or malformed. @@ -53,6 +73,96 @@ function readTyndaleConfig(): TyndaleConfigFile { } } +function selectExportPath(value: unknown): string | undefined { + if (typeof value === 'string') return value; + + if (Array.isArray(value)) { + for (const entry of value) { + const selected = selectExportPath(entry); + if (selected) return selected; + } + return undefined; + } + + if (!value || typeof value !== 'object') return undefined; + + const record = value as Record; + for (const condition of ['import', 'node', 'default', 'require', 'bun']) { + const selected = selectExportPath(record[condition]); + if (selected) return selected; + } + + return undefined; +} + +function selectPackageExport( + packageJson: PackageJson, + exportKey: string, +): string | undefined { + const exportsField = packageJson.exports; + + if (exportKey !== '.') { + return exportsField && + typeof exportsField === 'object' && + !Array.isArray(exportsField) + ? selectExportPath((exportsField as Record)[exportKey]) + : undefined; + } + const rootExport = + exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField) + ? Object.prototype.hasOwnProperty.call(exportsField, '.') + ? (exportsField as Record)['.'] + : exportsField + : exportsField; + + return ( + selectExportPath(rootExport) ?? + (typeof packageJson.module === 'string' ? packageJson.module : undefined) ?? + (typeof packageJson.main === 'string' ? packageJson.main : undefined) ?? + 'index.js' + ); +} + +function resolvePackageExport(packageName: string, exportKey: string): string { + const searchPaths = requireFromTyndaleNext.resolve.paths(packageName) ?? []; + + for (const nodeModulesPath of searchPaths) { + const packageDir = path.join(nodeModulesPath, packageName); + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) continue; + + let packageJson: PackageJson; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJson; + } catch { + throw new Error(`${packageName} package.json at ${packageJsonPath} is invalid JSON.`); + } + + const selectedExport = selectPackageExport(packageJson, exportKey); + if (!selectedExport) { + throw new Error( + `Unable to resolve ${packageName} ${exportKey} export from ${packageJsonPath}.`, + ); + } + + const entryPath = path.resolve(packageDir, selectedExport); + return fs.existsSync(entryPath) ? fs.realpathSync(entryPath) : entryPath; + } + + throw new Error( + `Unable to resolve ${packageName}. Install ${packageName} alongside tyndale-next.`, + ); +} + +function toTurbopackAliasPath(resolvedPath: string): string { + const relativePath = path + .relative(process.cwd(), resolvedPath) + .split(path.sep) + .join('/'); + + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +} + /** * Wraps a Next.js config to inject Tyndale build-time constants. * @@ -74,9 +184,16 @@ function readTyndaleConfig(): TyndaleConfigFile { export function withTyndaleConfig(nextConfig: NextConfig): NextConfig { const tyndaleConfig = readTyndaleConfig(); - // Resolve to the exact physical path so server and client bundles - // share the same tyndale-react instance (prevents duplicate React contexts). - const tyndaleReactPath = require.resolve('tyndale-react'); + // Resolve to the exact physical paths so server and client bundles share the + // same tyndale-react instance (prevents duplicate React contexts). Webpack + // prefix-matches bare alias keys, so its alias is exact-only; Turbopack gets + // explicit aliases for each public package subpath we use. + const tyndaleReactPath = resolvePackageExport('tyndale-react', '.'); + const tyndaleReactServerPath = resolvePackageExport('tyndale-react', './server'); + const tyndaleReactTurbopackPath = toTurbopackAliasPath(tyndaleReactPath); + const tyndaleReactServerTurbopackPath = toTurbopackAliasPath( + tyndaleReactServerPath, + ); return { ...nextConfig, @@ -90,12 +207,21 @@ export function withTyndaleConfig(nextConfig: NextConfig): NextConfig { ), TYNDALE_OUTPUT: tyndaleConfig.output, }, + turbopack: { + ...nextConfig.turbopack, + resolveAlias: { + ...nextConfig.turbopack?.resolveAlias, + 'tyndale-react': tyndaleReactTurbopackPath, + 'tyndale-react/server': tyndaleReactServerTurbopackPath, + }, + }, webpack: (config, options) => { // Chain with any existing webpack function from the user's config. const resolved = nextConfig.webpack ? nextConfig.webpack(config, options) : config; - resolved.resolve.alias['tyndale-react'] = tyndaleReactPath; + delete resolved.resolve.alias['tyndale-react']; + resolved.resolve.alias['tyndale-react$'] = tyndaleReactPath; return resolved; }, }; diff --git a/packages/tyndale-next/tests/config.test.ts b/packages/tyndale-next/tests/config.test.ts index 567af05..0a105fd 100644 --- a/packages/tyndale-next/tests/config.test.ts +++ b/packages/tyndale-next/tests/config.test.ts @@ -148,7 +148,31 @@ describe('withTyndaleConfig', () => { expect(typeof result.webpack).toBe('function'); }); - test('webpack function sets resolve.alias for tyndale-react', () => { + test('sets Turbopack resolve aliases for tyndale-react subpaths', () => { + writeConfig({ + defaultLocale: 'en', + locales: ['es'], + output: 'public/_tyndale', + localeAliases: {}, + }); + const result = withTyndaleConfig({ + turbopack: { + resolveAlias: { + custom: '/custom/path', + 'tyndale-react': '/stale-prefix-alias', + }, + }, + }); + expect(result.turbopack?.resolveAlias?.custom).toBe('/custom/path'); + expect(result.turbopack?.resolveAlias?.['tyndale-react']).toContain( + 'tyndale-react', + ); + expect(result.turbopack?.resolveAlias?.['tyndale-react/server']).toContain( + 'server', + ); + }); + + test('webpack function sets an exact resolve.alias for tyndale-react', () => { writeConfig({ defaultLocale: 'en', locales: ['es'], @@ -156,11 +180,17 @@ describe('withTyndaleConfig', () => { localeAliases: {}, }); const result = withTyndaleConfig({}); - const mockConfig = { resolve: { alias: {} as Record } }; + const mockConfig = { + resolve: { + alias: { 'tyndale-react': '/stale-prefix-alias' } as Record, + }, + }; const output = (result.webpack as Function)(mockConfig, {}); - expect(output.resolve.alias['tyndale-react']).toBeDefined(); - expect(typeof output.resolve.alias['tyndale-react']).toBe('string'); - expect(output.resolve.alias['tyndale-react']).toContain('tyndale-react'); + expect(output.resolve.alias['tyndale-react']).toBeUndefined(); + expect(output.resolve.alias['tyndale-react$']).toBeDefined(); + expect(output.resolve.alias['tyndale-react/server']).toBeUndefined(); + expect(typeof output.resolve.alias['tyndale-react$']).toBe('string'); + expect(output.resolve.alias['tyndale-react$']).toContain('tyndale-react'); }); test('webpack function chains with existing webpack', () => { @@ -182,7 +212,7 @@ describe('withTyndaleConfig', () => { const output = (result.webpack as Function)(mockConfig, {}); expect(userWebpackCalled).toBe(true); expect(output.resolve.alias['custom']).toBe('/custom/path'); - expect(output.resolve.alias['tyndale-react']).toBeDefined(); + expect(output.resolve.alias['tyndale-react$']).toBeDefined(); }); }); diff --git a/packages/tyndale-next/tests/exports.test.ts b/packages/tyndale-next/tests/exports.test.ts index 6880b1d..09f1b70 100644 --- a/packages/tyndale-next/tests/exports.test.ts +++ b/packages/tyndale-next/tests/exports.test.ts @@ -1,5 +1,31 @@ // packages/tyndale-next/tests/exports.test.ts import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as ts from 'typescript'; + +const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +function assertSpawnOk(result: ReturnType, label: string) { + if (result.status !== 0) { + throw new Error( + `${label} failed with exit ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + +function parseSpawnJson(result: ReturnType, label: string) { + try { + return JSON.parse(result.stdout) as Record; + } catch { + throw new Error( + `${label} emitted invalid JSON\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} describe('tyndale-next main exports', () => { test('exports TyndaleServerProvider', async () => { @@ -39,6 +65,109 @@ describe('tyndale-next/config subpath', () => { expect(mod.withTyndaleConfig).toBeDefined(); expect(typeof mod.withTyndaleConfig).toBe('function'); }); + test('loads from CommonJS and ESM config loaders', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'tyndale-next-exports-')); + + try { + const packageDir = join(workDir, 'node_modules', 'tyndale-next'); + const distDir = join(packageDir, 'dist'); + await mkdir(distDir, { recursive: true }); + await writeFile( + join(packageDir, 'package.json'), + await readFile(join(PKG_ROOT, 'package.json'), 'utf-8'), + ); + + const transpiled = ts.transpileModule( + await readFile(join(PKG_ROOT, 'src', 'config.ts'), 'utf-8'), + { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + }, + }, + ); + await writeFile(join(distDir, 'config.js'), transpiled.outputText); + await writeFile( + join(distDir, 'config.cjs'), + await readFile(join(PKG_ROOT, 'src', 'config.cjs'), 'utf-8'), + ); + + const reactDir = join(workDir, 'node_modules', 'tyndale-react'); + await mkdir(join(reactDir, 'dist'), { recursive: true }); + await writeFile( + join(reactDir, 'package.json'), + JSON.stringify({ + name: 'tyndale-react', + version: '0.0.0', + type: 'module', + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + }, + './server': { + types: './dist/server.d.ts', + import: './dist/server.js', + }, + }, + }), + ); + await writeFile(join(reactDir, 'dist', 'index.js'), 'export {};'); + await writeFile(join(reactDir, 'dist', 'server.js'), 'export {};'); + const expectedReactEntry = await realpath( + join(reactDir, 'dist', 'index.js'), + ); + const expectedReactTurbopackEntry = './node_modules/tyndale-react/dist/index.js'; + const expectedReactServerTurbopackEntry = + './node_modules/tyndale-react/dist/server.js'; + await writeFile( + join(workDir, 'tyndale.config.json'), + JSON.stringify({ + defaultLocale: 'en', + locales: ['es', 'fr'], + output: 'public/_tyndale', + localeAliases: {}, + }), + ); + + const cjs = spawnSync( + 'node', + [ + '-e', + "const { withTyndaleConfig } = require('tyndale-next/config'); const config = withTyndaleConfig({}); const aliases = config.webpack({ resolve: { alias: { 'tyndale-react': '/stale-prefix-alias' } } }, {}).resolve.alias; const turboAliases = config.turbopack.resolveAlias; console.log(JSON.stringify({ defaultLocale: config.env.TYNDALE_DEFAULT_LOCALE, rootAlias: aliases['tyndale-react$'] ?? null, prefixAlias: aliases['tyndale-react'] ?? null, serverAlias: aliases['tyndale-react/server'] ?? null, turboRootAlias: turboAliases['tyndale-react'] ?? null, turboServerAlias: turboAliases['tyndale-react/server'] ?? null }));", + ], + { cwd: workDir, encoding: 'utf-8' }, + ); + assertSpawnOk(cjs, 'CommonJS config loader'); + const cjsConfig = parseSpawnJson(cjs, 'CommonJS config loader'); + expect(cjsConfig.defaultLocale).toBe('en'); + expect(cjsConfig.rootAlias).toBe(expectedReactEntry); + expect(cjsConfig.prefixAlias).toBeNull(); + expect(cjsConfig.serverAlias).toBeNull(); + expect(cjsConfig.turboRootAlias).toBe(expectedReactTurbopackEntry); + expect(cjsConfig.turboServerAlias).toBe(expectedReactServerTurbopackEntry); + + const esm = spawnSync( + 'node', + [ + '--input-type=module', + '-e', + "import { withTyndaleConfig } from 'tyndale-next/config'; const config = withTyndaleConfig({}); const aliases = config.webpack({ resolve: { alias: { 'tyndale-react': '/stale-prefix-alias' } } }, {}).resolve.alias; const turboAliases = config.turbopack.resolveAlias; console.log(JSON.stringify({ locales: config.env.TYNDALE_LOCALES, rootAlias: aliases['tyndale-react$'] ?? null, prefixAlias: aliases['tyndale-react'] ?? null, serverAlias: aliases['tyndale-react/server'] ?? null, turboRootAlias: turboAliases['tyndale-react'] ?? null, turboServerAlias: turboAliases['tyndale-react/server'] ?? null }));", + ], + { cwd: workDir, encoding: 'utf-8' }, + ); + assertSpawnOk(esm, 'ESM config loader'); + const esmConfig = parseSpawnJson(esm, 'ESM config loader'); + expect(esmConfig.locales).toBe('["es","fr"]'); + expect(esmConfig.rootAlias).toBe(expectedReactEntry); + expect(esmConfig.prefixAlias).toBeNull(); + expect(esmConfig.serverAlias).toBeNull(); + expect(esmConfig.turboRootAlias).toBe(expectedReactTurbopackEntry); + expect(esmConfig.turboServerAlias).toBe(expectedReactServerTurbopackEntry); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); }); describe('tyndale-next/middleware subpath', () => { diff --git a/tests/e2e/fixture/next.config.mjs b/tests/e2e/fixture/next.config.mjs index 88c6781..2ba3a38 100644 --- a/tests/e2e/fixture/next.config.mjs +++ b/tests/e2e/fixture/next.config.mjs @@ -1,17 +1,6 @@ -import { readFileSync } from 'node:fs'; - -const raw = readFileSync(new URL('./tyndale.config.json', import.meta.url), 'utf-8'); -const tyndale = JSON.parse(raw); +import { withTyndaleConfig } from 'tyndale-next/config'; /** @type {import('next').NextConfig} */ -const nextConfig = { - env: { - TYNDALE_DEFAULT_LOCALE: tyndale.defaultLocale, - TYNDALE_LOCALES: JSON.stringify(tyndale.locales), - TYNDALE_COOKIE_NAME: 'TYNDALE_LOCALE', - TYNDALE_LOCALE_ALIASES: JSON.stringify(tyndale.localeAliases || {}), - TYNDALE_OUTPUT: tyndale.output || 'public/_tyndale', - }, -}; +const nextConfig = withTyndaleConfig({}); export default nextConfig;