import type { IncomingMessage, ServerResponse } from 'node:http';
import { parse } from 'node:url';
import {
type GetConfigurationOptions,
getConfiguration,
} from '@intlayer/config';
import { DefaultValues } from '@intlayer/config/client';
import {
getLocaleFromStorage,
localeDetector,
setLocaleInStorage,
} from '@intlayer/core';
import type { Locale } from '@intlayer/types';
/* @ts-ignore - Vite types error */
import type { Connect, Plugin } from 'vite';
type IntlayerProxyPluginOptions = {
/**
* A function that allows you to ignore specific requests from the intlayer proxy.
*
* @example
* ```ts
* export default defineConfig({
* plugins: [ intlayerProxyPlugin({ ignore: (req) => req.url?.startsWith('/api') }) ],
* });
* ```
*
* @param req - The incoming request.
* @returns A boolean value indicating whether to ignore the request.
*/
ignore?: (req: IncomingMessage) => boolean;
};
/**
*
* A Vite plugin that integrates a logic similar to the Next.js intlayer middleware.
*
* // Example usage of the plugin in a Vite configuration
* export default defineConfig({
* plugins: [ intlayerProxyPlugin() ],
* });
*
*/
export const intlayerProxy = (
configOptions?: GetConfigurationOptions,
options?: IntlayerProxyPluginOptions
): Plugin => {
const intlayerConfig = getConfiguration(configOptions);
const { internationalization, routing } = intlayerConfig;
const { locales: supportedLocales, defaultLocale } = internationalization;
const { basePath = '', mode = DefaultValues.Routing.ROUTING_MODE } = routing;
// Derived flags from routing.mode
const noPrefix = mode === 'no-prefix' || mode === 'search-params';
const prefixDefault = mode === 'prefix-all';
/* --------------------------------------------------------------------
* Helper & Utility Functions
* --------------------------------------------------------------------
*/
/**
* Retrieves the locale from storage (cookies, localStorage, sessionStorage).
*/
const getStorageLocale = (req: IncomingMessage): Locale | undefined => {
const locale = getLocaleFromStorage({
getCookie: (name: string) => {
const cookieHeader = req.headers.cookie ?? '';
const cookies = cookieHeader.split(';').reduce(
(acc, cookie) => {
const [key, val] = cookie.trim().split('=');
acc[key] = val;
return acc;
},
{} as Record<string, string>
);
return cookies[name] ?? null;
},
});
return locale;
};
/**
* Appends locale to search params when routing mode is 'search-params'.
*/
const appendLocaleSearchIfNeeded = (
search: string | undefined,
locale: Locale
): string | undefined => {
if (mode !== 'search-params') return search;
const params = new URLSearchParams(search ?? '');
params.set('locale', locale);
return `?${params.toString()}`;
};
/**
* Extracts the locale from the URL pathname if present as the first segment.
*/
const getPathLocale = (pathname: string): Locale | undefined => {
// e.g. if pathname is /en/some/page or /en
// we check if "en" is in your supportedLocales
const segments = pathname.split('/').filter(Boolean);
const firstSegment = segments[0];
if (firstSegment && supportedLocales.includes(firstSegment as Locale)) {
return firstSegment as Locale;
}
return undefined;
};
/**
* Writes a 301 redirect response with the given new URL.
*/
const redirectUrl = (
res: ServerResponse<IncomingMessage>,
newUrl: string
) => {
res.writeHead(301, { Location: newUrl });
return res.end();
};
/**
* "Rewrite" the request internally by adjusting req.url;
* we also set the locale in the response header if needed.
*/
const rewriteUrl = (
req: Connect.IncomingMessage,
res: ServerResponse<IncomingMessage>,
newUrl: string,
locale?: Locale
) => {
req.url = newUrl;
// If you want to mimic Next.js's behavior of setting a header for the locale:
if (locale) {
setLocaleInStorage(locale, {
setHeader: (name: string, value: string) => res.setHeader(name, value),
});
}
};
/**
* Constructs a new path string, optionally including a locale prefix, basePath, and search parameters.
* - basePath: (e.g., '/myapp')
* - locale: (e.g., 'en')
* - currentPath:(e.g., '/products/shoes')
* - search: (e.g., '?foo=bar')
*/
const constructPath = (
locale: Locale,
currentPath: string,
search?: string
) => {
// Strip any incoming locale prefix if present
const pathWithoutPrefix = currentPath.startsWith(`/${locale}`)
? (currentPath.slice(`/${locale}`.length) ?? '/')
: currentPath;
// Ensure basePath always starts with '/', and remove trailing slash if needed
const cleanBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
// If basePath is '/', no trailing slash is needed
const normalizedBasePath = cleanBasePath === '/' ? '' : cleanBasePath;
// In 'search-params' and 'no-prefix' modes, do not prefix the path with the locale
if (mode === 'no-prefix') {
const newPath = search
? `${pathWithoutPrefix}${search}`
: pathWithoutPrefix;
return newPath;
}
if (mode === 'search-params') {
const newPath = search
? `${pathWithoutPrefix}${search}`
: pathWithoutPrefix;
return newPath;
}
// Check if path already starts with locale to avoid double-prefixing
const pathWithLocalePrefix = currentPath.startsWith(`/${locale}`)
? currentPath
: `/${locale}${currentPath}`;
const basePathTrailingSlash = basePath.endsWith('/');
let newPath = `${normalizedBasePath}${basePathTrailingSlash ? '' : ''}${pathWithLocalePrefix}`;
// Special case: if prefixDefault is false and locale is defaultLocale, remove the locale prefix
if (!prefixDefault && locale === defaultLocale) {
newPath = `${normalizedBasePath}${pathWithoutPrefix}`;
}
// Append search parameters if provided
if (search) {
newPath += search;
}
return newPath;
};
/* --------------------------------------------------------------------
* Handlers that mirror Next.js style logic
* --------------------------------------------------------------------
*/
/**
* If `noPrefix` is true, we never prefix the locale in the URL.
* We simply rewrite the request to the same path, but with the best-chosen locale
* in a header or search params if desired.
*/
const handleNoPrefix = ({
req,
res,
next,
originalPath,
searchParams,
storageLocale,
}: {
req: Connect.IncomingMessage;
res: ServerResponse<IncomingMessage>;
next: Connect.NextFunction;
originalPath: string;
searchParams: string;
storageLocale?: Locale;
}) => {
// Determine the best locale
let locale = storageLocale ?? defaultLocale;
// Use fallback to localeDetector if no storage locale
if (!storageLocale) {
const detectedLocale = localeDetector(
req.headers as Record<string, string>,
supportedLocales,
defaultLocale
);
locale = detectedLocale as Locale;
}
// In search-params mode, we need to redirect to add the locale search param
if (mode === 'search-params') {
// Check if locale search param already exists and matches the detected locale
const existingSearchParams = new URLSearchParams(searchParams ?? '');
const existingLocale = existingSearchParams.get('locale');
// If the existing locale matches the detected locale, no redirect needed
if (existingLocale === locale) {
// For internal routing, we need to add the locale prefix so the framework can match [locale] param
const internalPath = `/${locale}${originalPath}`;
const rewritePath = `${internalPath}${searchParams ?? ''}`;
// Rewrite internally (URL stays the same in browser, but internally routes to /[locale]/path)
rewriteUrl(req, res, rewritePath, locale);
return next();
}
// Locale param missing or doesn't match - redirect to add/update it
const search = appendLocaleSearchIfNeeded(searchParams, locale);
const redirectPath = search
? `${originalPath}${search}`
: `${originalPath}${searchParams ?? ''}`;
// Redirect to add/update the locale search param (URL changes in browser)
return redirectUrl(res, redirectPath);
}
// For no-prefix mode (not search-params), add locale prefix internally for routing
const internalPath = `/${locale}${originalPath}`;
// Add search params if needed
const search = appendLocaleSearchIfNeeded(searchParams, locale);
const rewritePath = search
? `${internalPath}${search}`
: `${internalPath}${searchParams ?? ''}`;
// Rewrite internally (URL stays the same in browser, but internally routes to /[locale]/path)
rewriteUrl(req, res, rewritePath, locale);
return next();
};
/**
* The main prefix logic:
* - If there's no pathLocale in the URL, we might want to detect & redirect or rewrite
* - If there is a pathLocale, handle storage mismatch or default locale special cases
*/
const handlePrefix = ({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
storageLocale,
}: {
req: Connect.IncomingMessage;
res: ServerResponse<IncomingMessage>;
next: Connect.NextFunction;
originalPath: string;
searchParams: string;
pathLocale?: Locale;
storageLocale?: Locale;
}) => {
// 1. If pathLocale is missing, handle
if (!pathLocale) {
handleMissingPathLocale({
req,
res,
next,
originalPath,
searchParams,
storageLocale,
});
return;
}
// 2. If pathLocale exists, handle it
handleExistingPathLocale({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
});
};
/**
* Handles requests where the locale is missing from the URL pathname.
* We detect a locale from storage / headers / default, then either redirect or rewrite.
*/
const handleMissingPathLocale = ({
req,
res,
next,
originalPath,
searchParams,
storageLocale,
}: {
req: Connect.IncomingMessage;
res: ServerResponse<IncomingMessage>;
next: Connect.NextFunction;
originalPath: string;
searchParams: string;
storageLocale?: Locale;
}) => {
// 1. Choose the best locale
let locale = (storageLocale ??
localeDetector(
req.headers as Record<string, string>,
supportedLocales,
defaultLocale
)) as Locale;
// 2. If still invalid, fallback
if (!supportedLocales.includes(locale)) {
locale = defaultLocale;
}
// 3. Construct new path - preserving original search params
const search = appendLocaleSearchIfNeeded(searchParams, locale);
const newPath = constructPath(locale, originalPath, search);
// If we always prefix default or if this is not the default locale, do a 301 redirect
// so that the user sees the locale in the URL.
if (prefixDefault || locale !== defaultLocale) {
return redirectUrl(res, newPath);
}
// If we do NOT prefix the default locale, just rewrite in place
rewriteUrl(req, res, newPath, locale);
return next();
};
/**
* Handles requests where the locale prefix is present in the pathname.
*/
const handleExistingPathLocale = ({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
}: {
req: Connect.IncomingMessage;
res: ServerResponse<IncomingMessage>;
next: Connect.NextFunction;
originalPath: string;
searchParams: string;
pathLocale: Locale;
}) => {
// In prefix modes, respect the URL path locale
// The path locale takes precedence, and we'll update storage to match
handleDefaultLocaleRedirect({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
});
};
/**
* If the path locale is the default locale but we don't want to prefix the default, remove it.
*/
const handleDefaultLocaleRedirect = ({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
}: {
req: Connect.IncomingMessage;
res: ServerResponse<IncomingMessage>;
next: Connect.NextFunction;
originalPath: string;
searchParams: string;
pathLocale: Locale;
}) => {
// If we don't prefix default AND the path locale is the default locale -> remove it
if (!prefixDefault && pathLocale === defaultLocale) {
// Remove the default locale part from the path
let newPath = originalPath.replace(`/${defaultLocale}`, '') || '/';
// In prefix modes, don't add locale to search params
// Just preserve the original search params if they exist
if (searchParams) {
newPath += searchParams;
}
rewriteUrl(req, res, newPath, pathLocale);
return next();
}
// If we do prefix default or pathLocale != default, keep as is, but rewrite headers
// Preserve original search params without adding locale
const newPath = searchParams
? `${originalPath}${searchParams}`
: originalPath;
rewriteUrl(req, res, newPath, pathLocale);
return next();
};
return {
name: 'vite-intlayer-middleware-plugin',
configureServer: (server) => {
server.middlewares.use((req, res, next) => {
// Bypass assets and special Vite endpoints
if (
// Custom ignore function
(options?.ignore?.(req) ?? false) ||
req.url?.startsWith('/node_modules') ||
/**
* /^@vite/ # HMR client and helpers
* /^@fs/ # file-system import serving
* /^@id/ # virtual module ids
* /^@tanstack/start-router-manifest # Tanstack Start Router manifest
*/
req.url?.startsWith('/@') ||
/**
* /^__vite_ping$ # health ping
* /^__open-in-editor$
* /^__manifest$ # Remix/RR7 lazyRouteDiscovery
*/
req.url?.startsWith('/_') ||
/**
* ./myFile.js
*/
req.url
?.split('?')[0]
.match(/\.[a-z]+$/i) // checks for file extensions
) {
return next();
}
// Parse original URL for path and query
const parsedUrl = parse(req.url ?? '/', true);
const originalPath = parsedUrl.pathname ?? '/';
const searchParams = parsedUrl.search ?? '';
// Check if there's a locale prefix in the path FIRST
const pathLocale = getPathLocale(originalPath);
// Attempt to read the locale from storage (cookies, localStorage, etc.)
const storageLocale = getStorageLocale(req);
// CRITICAL FIX: If there's a valid pathLocale, it takes precedence over storage
// This prevents race conditions when cookies are stale during locale switches
const effectiveStorageLocale =
pathLocale && supportedLocales.includes(pathLocale)
? pathLocale
: storageLocale;
// If noPrefix is true, we skip prefix logic altogether
if (noPrefix) {
handleNoPrefix({
req,
res,
next,
originalPath,
searchParams,
storageLocale: effectiveStorageLocale,
});
return;
}
// Otherwise, handle prefix logic
handlePrefix({
req,
res,
next,
originalPath,
searchParams,
pathLocale,
storageLocale: effectiveStorageLocale,
});
});
},
};
};
/**
* @deprecated Rename to intlayerProxy instead
*
* A Vite plugin that integrates a logic similar to the Next.js intlayer middleware.
*
* ```ts
* // Example usage of the plugin in a Vite configuration
* export default defineConfig({
* plugins: [ intlayerMiddleware() ],
* });
* ```
*/
export const intlayerMiddleware = intlayerProxy;
/**
* @deprecated Rename to intlayerProxy instead
*
* A Vite plugin that integrates a logic similar to the Next.js intlayer middleware.
* ```ts
* // Example usage of the plugin in a Vite configuration
* export default defineConfig({
* plugins: [ intlayerMiddleware() ],
* });
* ```
*/
export const intLayerMiddlewarePlugin = intlayerProxy;