Skip to content
Merged
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
9 changes: 5 additions & 4 deletions .github/workflows/portability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/tyndale-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
168 changes: 168 additions & 0 deletions packages/tyndale-next/src/config.cjs
Original file line number Diff line number Diff line change
@@ -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,
};
134 changes: 130 additions & 4 deletions packages/tyndale-next/src/config.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -22,12 +25,29 @@ interface WebpackConfig {

type WebpackOptions = Record<string, unknown>;

type TurbopackResolveAlias = Record<
string,
string | string[] | Record<string, string | string[]>
>;

interface TurbopackConfig {
resolveAlias?: TurbopackResolveAlias;
[key: string]: unknown;
}

interface NextConfig {
env?: Record<string, string>;
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.
Expand All @@ -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<string, unknown>;
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<string, unknown>)[exportKey])
: undefined;
}
const rootExport =
exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField)
? Object.prototype.hasOwnProperty.call(exportsField, '.')
? (exportsField as Record<string, unknown>)['.']
: 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.
*
Expand All @@ -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,
Expand All @@ -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;
},
};
Expand Down
Loading
Loading