build(editor): Resolve frontend workspace packages from source in Vite (no-changelog) (#36131)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Alex Grozav
2026-08-17 10:18:01 +00:00
committed by GitHub
co-authored by multica-agent
parent f44bd4827f
commit 3beabfb66f
17 changed files with 383 additions and 300 deletions
+1
View File
@@ -104,6 +104,7 @@ jobs:
packages/frontend/editor-ui/vite.config.mts
pnpm-workspace.yaml
packages/@n8n/*/package.json
packages/frontend/@n8n/frontend-vite-config/**
packages/testing/playwright/tests/dev-server-smoke/**
packages/testing/playwright/playwright.config.ts
packages/testing/playwright/playwright-projects.ts
@@ -1,52 +0,0 @@
import { resolve } from 'node:path';
import type { Alias } from 'vite';
import { frontendModuleAliases, frontendSourceAliases } from './source-packages.js';
/**
* Packages reached *transitively*: they are not a declared dependency of anyone, so they cannot sit
* in the source-packages table.
*
* `n8n-workflow`'s expression-sandboxing imports `astVisit` from `@n8n/tournament`, whose dist is
* CJS. Linked workspace packages skip optimizeDeps, so the dev server serves that file verbatim and
* the browser fails to parse a named export out of it; the build survives because rolldown interops
* CJS, but pays ~397 kB in defeated tree-shaking. Resolving to src avoids both.
*/
export const transitiveWorkspaceAliases = (packagesDir: string): Alias[] => [
{ find: '@n8n/tournament', replacement: resolve(packagesDir, '@n8n', 'tournament', 'src') },
];
/**
* Shell-only, deliberately out of `frontendAliases`: these replacements are bare specifiers
* resolved from the consumer's own `node_modules`, and only editor-ui declares `lodash` and
* `stream-browserify`. Shared with a module's vitest, a value import of `stream` anywhere in its
* graph would fail to resolve — latent today only because `n8n-workflow` imports `stream` as a
* type.
*/
export const vendorAliases = (): Alias[] => [
...['orderBy', 'camelCase', 'cloneDeep', 'startCase'].map((name) => ({
find: new RegExp(`^lodash.${name}$`, 'i'),
replacement: `lodash/${name}`,
})),
{ find: /^lodash\.(.+)$/, replacement: 'lodash/$1' },
// `n8n-workflow` reaches Node's `stream` in a browser graph.
{ find: 'stream', replacement: 'stream-browserify' },
];
/**
* Every entry rewrites to an absolute path under `packages/`, which is what makes the set safe to
* share from any directory — `aliases.test.ts` holds that line.
*/
export const frontendAliases = (packagesDir: string): Alias[] => [
...frontendSourceAliases(packagesDir),
...transitiveWorkspaceAliases(packagesDir),
];
/**
* Order is resolution order: vendor rewrites stay last, where they sat before this package existed.
*/
export const shellAliases = (packagesDir: string): Alias[] => [
...frontendAliases(packagesDir),
...frontendModuleAliases(packagesDir),
...vendorAliases(),
];
@@ -1,4 +0,0 @@
{
"$schema": "../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../biome.jsonc"]
}
@@ -1,14 +0,0 @@
export {
frontendAliases,
shellAliases,
transitiveWorkspaceAliases,
vendorAliases,
} from './aliases.js';
export {
frontendModuleAliases,
frontendSourceAliases,
// The tables themselves, for the guard test that checks them against tsconfig `paths`.
modulePackages,
sourcePackages,
} from './source-packages.js';
@@ -1,68 +0,0 @@
import { resolve } from 'node:path';
import type { Alias } from 'vite';
/**
* Workspace packages the frontend consumes from source rather than from `dist`, so that an edit in
* one of them hot-reloads the editor without a rebuild. `dir` is relative to `packages/`.
*
* Must stay in step with editor-ui's tsconfig `paths` and with `tsconfig.frontend-module.json`:
* when those disagreed, vue-tsc typechecked a package from `src` while the bundle was built from
* its `dist`. `editor-ui/vite/aliases.test.ts` fails when they diverge and names what to update.
*
* It lives here rather than in editor-ui because every `packages/modules/<name>/frontend` needs the
* same mapping for its own vitest run, and a module cannot import from the shell it plugs into.
*
* `entry: false` marks packages with no `src/index.ts`. Their `exports` map has no `.`, so a bare
* import of them does not resolve at all and must not be aliased.
*/
export const sourcePackages = [
{ name: '@n8n/api-types', dir: '@n8n/api-types' },
{ name: '@n8n/chat', dir: 'frontend/@n8n/chat' },
{ name: '@n8n/chat-hub', dir: '@n8n/chat-hub' },
{ name: '@n8n/composables', dir: 'frontend/@n8n/composables', entry: false },
{ name: '@n8n/constants', dir: '@n8n/constants' },
{ name: '@n8n/design-system', dir: 'frontend/@n8n/design-system' },
{ name: '@n8n/frontend-constants', dir: 'frontend/@n8n/frontend-constants', entry: false },
{ name: '@n8n/frontend-module-sdk', dir: 'frontend/@n8n/frontend-module-sdk' },
{ name: '@n8n/frontend-utils', dir: 'frontend/@n8n/frontend-utils', entry: false },
{ name: '@n8n/i18n', dir: 'frontend/@n8n/i18n' },
{ name: '@n8n/rest-api-client', dir: 'frontend/@n8n/rest-api-client' },
{ name: '@n8n/stores', dir: 'frontend/@n8n/stores' },
{ name: '@n8n/telemetry', dir: '@n8n/telemetry' },
{ name: '@n8n/utils', dir: '@n8n/utils', entry: false },
];
/**
* Feature module packages, appended by `n8n-module-sdk create`. Kept separate from the table above
* because only the shell may resolve them: a module aliasing its siblings would let an accidental
* cross-module import resolve at test time, which is the boundary the module tsconfig base holds.
*/
export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [];
const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Each package gets a slash-delimited, anchored pair — `^@n8n/chat$` and `^@n8n/chat/(.+)$`. One
* open-ended `^@n8n/chat(.+)$` would match `@n8n/chat-hub/…` as well, and would leave the bare
* `@n8n/chat` unmatched — `(.+)` needs a character after the package name — falling through to
* `dist`.
*/
const expand = (packagesDir: string, packages: typeof modulePackages): Alias[] =>
packages.flatMap(({ name, dir, entry = true }) => {
const src = resolve(packagesDir, dir, 'src');
const pattern = escapeForRegExp(name);
return [
...(entry
? [{ find: new RegExp(`^${pattern}$`), replacement: resolve(src, 'index.ts') }]
: []),
{ find: new RegExp(`^${pattern}/(.+)$`), replacement: `${src}/$1` },
];
});
export const frontendSourceAliases = (packagesDir: string): Alias[] =>
expand(packagesDir, sourcePackages);
/** Shell-only — see `modulePackages`. */
export const frontendModuleAliases = (packagesDir: string): Alias[] =>
expand(packagesDir, modulePackages);
@@ -1,10 +0,0 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": ".",
"outDir": "dist"
},
"include": ["**/*.ts"],
"exclude": ["dist", "**/*.test.ts"]
}
@@ -1,10 +0,0 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node"],
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["**/*.ts"],
"exclude": ["dist"]
}
@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}
@@ -0,0 +1,131 @@
import { resolve } from 'node:path';
import type { Alias } from 'vite';
// Edit the two tables that follow. Keep the tables at the top of this file.
/**
* The frontend reads these workspace packages from source, not from `dist`. An edit in one of them
* hot-reloads the editor with no rebuild. `dir` is relative to `packages/`.
*
* Keep this table in step with the `paths` in `editor-ui/tsconfig.json` and in
* `tsconfig.frontend-module.json`. When they disagreed, vue-tsc read a package from `src`, but the
* bundle used the `dist` of that package. `editor-ui/vite/aliases.test.ts` fails when they
* disagree. The test names the file to correct.
*
* This table stays here, not in editor-ui. Each `packages/modules/<name>/frontend` needs the same
* map for its own vitest run. A module must not import from the shell.
*
* `entry: false` marks a package with no `src/index.ts`. The `exports` map of such a package has no
* `.` key. A bare import of it does not resolve, so do not make an alias for it.
*/
export const sourcePackages = [
{ name: '@n8n/api-types', dir: '@n8n/api-types' },
{ name: '@n8n/chat', dir: 'frontend/@n8n/chat' },
{ name: '@n8n/chat-hub', dir: '@n8n/chat-hub' },
{ name: '@n8n/composables', dir: 'frontend/@n8n/composables', entry: false },
{ name: '@n8n/constants', dir: '@n8n/constants' },
{ name: '@n8n/design-system', dir: 'frontend/@n8n/design-system' },
{ name: '@n8n/frontend-constants', dir: 'frontend/@n8n/frontend-constants', entry: false },
{ name: '@n8n/frontend-module-sdk', dir: 'frontend/@n8n/frontend-module-sdk' },
{ name: '@n8n/frontend-utils', dir: 'frontend/@n8n/frontend-utils', entry: false },
{ name: '@n8n/i18n', dir: 'frontend/@n8n/i18n' },
{ name: '@n8n/rest-api-client', dir: 'frontend/@n8n/rest-api-client' },
{ name: '@n8n/stores', dir: 'frontend/@n8n/stores' },
{ name: '@n8n/telemetry', dir: '@n8n/telemetry' },
{ name: '@n8n/utils', dir: '@n8n/utils', entry: false },
];
/**
* `n8n-module-sdk create` adds a feature module package to this table. Only the shell resolves
* these packages, so they stay out of the table above.
*
* A module that aliases the other modules lets a cross-module import resolve in its own test run.
* The module tsconfig base holds that boundary.
*/
export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [];
// The code below makes the Vite aliases from the two tables. Keep this code in the same file as
// the tables. A second file needs an import with a `.ts` specifier. That import causes error
// TS5097 in each package that imports this one.
const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Each package gets two anchored patterns: `^@n8n/chat$` and `^@n8n/chat/(.+)$`. The slash keeps
* them apart.
*
* One open pattern `^@n8n/chat(.+)$` also matches `@n8n/chat-hub/…`. It does not match the bare
* `@n8n/chat`, because `(.+)` needs one more character after the name. That bare import then
* resolves to `dist`.
*/
const expand = (packagesDir: string, packages: typeof modulePackages): Alias[] =>
packages.flatMap(({ name, dir, entry = true }) => {
const src = resolve(packagesDir, dir, 'src');
const pattern = escapeForRegExp(name);
return [
...(entry
? [{ find: new RegExp(`^${pattern}$`), replacement: resolve(src, 'index.ts') }]
: []),
{ find: new RegExp(`^${pattern}/(.+)$`), replacement: `${src}/$1` },
];
});
export const frontendSourceAliases = (packagesDir: string): Alias[] =>
expand(packagesDir, sourcePackages);
/** Only the shell uses this map. See `modulePackages`. */
export const frontendModuleAliases = (packagesDir: string): Alias[] =>
expand(packagesDir, modulePackages);
/**
* `n8n-workflow` imports `astVisit` from `@n8n/tournament` for its expression sandbox. Nothing
* declares `@n8n/tournament` as a dependency, so it cannot go in the table above.
*
* The `dist` of `@n8n/tournament` is CJS. Vite skips optimizeDeps for a linked workspace package,
* so the dev server sends that file as it is. The browser then fails to parse a named export from
* it.
*
* The production build works, because rolldown reads CJS. But the build loses tree-shaking and
* adds approximately 397 kB. An alias to `src` prevents both problems.
*/
export const transitiveWorkspaceAliases = (packagesDir: string): Alias[] => [
{ find: '@n8n/tournament', replacement: resolve(packagesDir, '@n8n', 'tournament', 'src') },
];
/**
* Only the shell uses these rewrites, so they stay out of `frontendAliases`. Each one points to a
* bare specifier, which resolves from the `node_modules` of the consumer. Only editor-ui declares
* `lodash` and `stream-browserify`.
*
* If a module vitest run used this map, a value import of `stream` in its graph would not resolve.
* The problem is hidden today, because `n8n-workflow` imports `stream` as a type only.
*/
export const vendorAliases = (): Alias[] => [
...['orderBy', 'camelCase', 'cloneDeep', 'startCase'].map((name) => ({
find: new RegExp(`^lodash.${name}$`, 'i'),
replacement: `lodash/${name}`,
})),
{ find: /^lodash\.(.+)$/, replacement: 'lodash/$1' },
// `n8n-workflow` uses the `stream` module of Node in a browser graph.
{ find: 'stream', replacement: 'stream-browserify' },
];
/**
* Each entry points to an absolute path under `packages/`. A consumer in any directory can use this
* set, because the paths are absolute. `aliases.test.ts` tests that rule.
*/
export const frontendAliases = (packagesDir: string): Alias[] => [
...frontendSourceAliases(packagesDir),
...transitiveWorkspaceAliases(packagesDir),
];
/**
* Vite uses the first match, so the order here is the resolution order. The vendor rewrites stay
* last, where they were before this package existed.
*/
export const shellAliases = (packagesDir: string): Alias[] => [
...frontendAliases(packagesDir),
...frontendModuleAliases(packagesDir),
...vendorAliases(),
];
@@ -11,22 +11,15 @@
"typescript": "catalog:typescript",
"vite": "catalog:"
},
"main": "index.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
".": "./index.ts"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"clean": "rimraf .turbo",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"watch": "tsc -p tsconfig.build.json --watch"
"format:check": "biome ci ."
},
"license": "LicenseRef-n8n-sustainable-use"
}
@@ -0,0 +1,11 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["node"],
"module": "ESNext",
"moduleResolution": "bundler",
// The frontend reads this package from source. No command must emit a `.js` file here.
"noEmit": true
},
"include": ["**/*.ts"]
}
+1
View File
@@ -135,6 +135,7 @@
"@faker-js/faker": "^8.0.2",
"@iconify/json": "catalog:",
"@n8n/eslint-config": "workspace:*",
"@n8n/frontend-vite-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
+41 -119
View File
@@ -1,12 +1,13 @@
// Each import in this file must resolve with no build step.
// Put an import that needs a `dist` in `vitest.config.mts`.
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { defineConfig, mergeConfig, type UserConfig } from 'vite';
import { defineConfig, type UserConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import svgLoader from 'vite-svg-loader';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { codecovVitePlugin } from '@codecov/vite-plugin';
import { vitestConfig } from '@n8n/vitest-config/frontend';
import icons from 'unplugin-icons/vite';
import { lucideIconsPlugin } from '../@n8n/design-system/src/icons/lucide/vite';
import browserslistToEsbuild from 'browserslist-to-esbuild';
@@ -14,6 +15,7 @@ import legacy from '@vitejs/plugin-legacy';
import browserslist from 'browserslist';
import { isLocaleFile, sendLocaleUpdate } from './vite/i18n-locales-hmr-helpers';
import { nodePopularityPlugin } from './vite/vite-plugin-node-popularity.mjs';
import { editorUiAliases } from './vite/aliases.mjs';
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/';
@@ -27,84 +29,7 @@ const packagesDir = resolve(__dirname, '..', '..');
// Vite resolves it to a single copy. The other curated libs are backend-only.
const singleInstanceDedupe = ['zod'];
const alias = [
{ find: '@', replacement: resolve(__dirname, 'src') },
{ find: 'stream', replacement: 'stream-browserify' },
// Stub out @n8n/expression-runtime for browser build (it pulls in isolated-vm, a Node.js-only native module)
{
find: '@n8n/expression-runtime',
replacement: resolve(__dirname, 'vite/expression-runtime-stub.ts'),
},
// Ensure bare imports resolve to sources (not dist)
{ find: '@n8n/i18n', replacement: resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src') },
{ find: '@n8n/chat-hub', replacement: resolve(packagesDir, '@n8n', 'chat-hub', 'src') },
{ find: '@n8n/tournament', replacement: resolve(packagesDir, '@n8n', 'tournament', 'src') },
{
find: /^@n8n\/chat(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'chat', 'src$1'),
},
{
find: /^@n8n\/chat-hub(.+)$/,
replacement: resolve(packagesDir, '@n8n', 'chat-hub', 'src$1'),
},
{
find: /^@n8n\/api-requests(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'api-requests', 'src$1'),
},
{
find: /^@n8n\/composables(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'composables', 'src$1'),
},
{
find: /^@n8n\/frontend-module-sdk$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'frontend-module-sdk', 'src/index.ts'),
},
{
find: /^@n8n\/constants(.+)$/,
replacement: resolve(packagesDir, '@n8n', 'constants', 'src$1'),
},
{
find: /^@n8n\/design-system$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'design-system', 'src/index.ts'),
},
{
find: /^@n8n\/design-system(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'design-system', 'src$1'),
},
{
find: /^@n8n\/i18n(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src$1'),
},
{
find: /^@n8n\/stores(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'stores', 'src$1'),
},
{
find: /^@n8n\/telemetry$/,
replacement: resolve(packagesDir, '@n8n', 'telemetry', 'src/index.ts'),
},
{
find: /^@n8n\/telemetry(.+)$/,
replacement: resolve(packagesDir, '@n8n', 'telemetry', 'src$1'),
},
{
find: /^@n8n\/utils(.+)$/,
replacement: resolve(packagesDir, '@n8n', 'utils', 'src$1'),
},
...['orderBy', 'camelCase', 'cloneDeep', 'startCase'].map((name) => ({
find: new RegExp(`^lodash.${name}$`, 'i'),
replacement: `lodash/${name}`,
})),
{
find: /^lodash\.(.+)$/,
replacement: 'lodash/$1',
},
{
// For sanitize-html
find: 'source-map-js',
replacement: resolve(__dirname, 'vite/source-map-js-shim'),
},
];
const alias = editorUiAliases(__dirname, packagesDir);
const { RELEASE: release } = process.env;
@@ -229,44 +154,41 @@ const plugins: UserConfig['plugins'] = [
const target = browserslistToEsbuild(browsers);
export default mergeConfig(
defineConfig({
define: {
// This causes test to fail but is required for actually running it
// ...(NODE_ENV !== 'test' ? { 'global': 'globalThis' } : {}),
...(NODE_ENV === 'development' ? { 'process.env': {} } : {}),
BASE_PATH: `'${publicPath}'`,
},
plugins,
resolve: { alias, dedupe: singleInstanceDedupe },
base: publicPath,
envPrefix: ['VUE', 'N8N_ENV_FEAT'],
css: {
preprocessorMaxWorkers: 2,
preprocessorOptions: {
scss: {
additionalData: [
'',
'@use "@/app/css/_variables.scss" as *;',
'@use "@n8n/design-system/css/mixins" as mixins;',
].join('\n'),
},
export default defineConfig({
define: {
// This causes test to fail but is required for actually running it
// ...(NODE_ENV !== 'test' ? { 'global': 'globalThis' } : {}),
...(NODE_ENV === 'development' ? { 'process.env': {} } : {}),
BASE_PATH: `'${publicPath}'`,
},
plugins,
resolve: { alias, dedupe: singleInstanceDedupe },
base: publicPath,
envPrefix: ['VUE', 'N8N_ENV_FEAT'],
css: {
preprocessorMaxWorkers: 2,
preprocessorOptions: {
scss: {
additionalData: [
'',
'@use "@/app/css/_variables.scss" as *;',
'@use "@n8n/design-system/css/mixins" as mixins;',
].join('\n'),
},
},
build: {
minify: !!release,
// Coverage builds emit INLINE maps so browser V8 coverage carries the
// map in the script source and monocart resolves offsets back to src.
sourcemap: process.env.BUILD_WITH_COVERAGE === 'true' ? 'inline' : !!release,
target,
},
optimizeDeps: {
exclude: ['wa-sqlite'],
rolldownOptions: {},
},
worker: {
format: 'es',
},
}),
vitestConfig,
);
},
build: {
minify: !!release,
// Coverage builds emit INLINE maps so browser V8 coverage carries the
// map in the script source and monocart resolves offsets back to src.
sourcemap: process.env.BUILD_WITH_COVERAGE === 'true' ? 'inline' : !!release,
target,
},
optimizeDeps: {
exclude: ['wa-sqlite'],
rolldownOptions: {},
},
worker: {
format: 'es',
},
});
@@ -0,0 +1,25 @@
import { resolve } from 'path';
import type { Alias } from 'vite';
// `@n8n/frontend-vite-config` holds the part that modules also use. Each module needs the same
// map for its own vitest run. A module must not import from the shell.
import { shellAliases } from '@n8n/frontend-vite-config';
export const appAliases = (editorUiDir: string): Alias[] => [
{ find: '@', replacement: resolve(editorUiDir, 'src') },
// Stub out @n8n/expression-runtime for browser build (it pulls in isolated-vm, a Node.js-only native module)
{
find: '@n8n/expression-runtime',
replacement: resolve(editorUiDir, 'vite/expression-runtime-stub.ts'),
},
{
// For sanitize-html
find: 'source-map-js',
replacement: resolve(editorUiDir, 'vite/source-map-js-shim'),
},
];
export const editorUiAliases = (editorUiDir: string, packagesDir: string): Alias[] => [
...appAliases(editorUiDir),
...shellAliases(packagesDir),
];
@@ -0,0 +1,142 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import type { Alias } from 'vite';
import { describe, expect, it } from 'vitest';
import { frontendAliases, modulePackages, sourcePackages } from '@n8n/frontend-vite-config';
import { editorUiAliases } from './aliases.mjs';
// vitest sets the cwd to the package root. Under jsdom, `import.meta.url` is not a file URL.
const editorUiDir = process.cwd();
const packagesDir = resolve(editorUiDir, '..', '..');
const repoRoot = resolve(packagesDir, '..');
const MODULE_TSCONFIG = join(repoRoot, 'packages', '@n8n', 'typescript-config');
/** A tsconfig file is JSONC. The frontend ones carry only whole-line `//` comments. */
const readTsconfig = (file: string) =>
JSON.parse(
readFileSync(file, 'utf8')
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n'),
) as { compilerOptions?: { paths?: Record<string, string[]> } };
/** This function makes the short `"@n8n/x*"` form equal to the `"@n8n/x"` plus `"@n8n/x/*"` pair. */
const pathsByPackage = (file: string) => {
const paths = readTsconfig(file).compilerOptions?.paths ?? {};
const byPackage = new Map<string, string>();
for (const [key, [target]] of Object.entries(paths)) {
const name = key.replace(/\/?\*$/, '');
if (!name.startsWith('@n8n/')) continue;
const resolved = resolve(dirname(file), target.replace(/\/?\*$/, ''));
byPackage.set(name, resolved.endsWith('.ts') ? dirname(resolved) : resolved);
}
return byPackage;
};
/**
* This function copies the resolve plugin of vite. The first match wins. The function then calls
* `String.replace`.
*
* If no pattern matches, node resolution takes the specifier. Node resolution finds the built
* `dist` of the package.
*/
const resolveSpecifier = (specifier: string, aliases: Alias[]): string => {
const matched = aliases.find(({ find }) =>
typeof find === 'string'
? specifier === find || specifier.startsWith(`${find}/`)
: find.test(specifier),
);
if (!matched) return 'dist';
const target = specifier.replace(matched.find, matched.replacement);
// A directory target and its `index.ts` are the same module. Only the text is different.
const asIndex = join(target, 'index.ts');
return relative(repoRoot, existsSync(asIndex) ? asIndex : target);
};
describe('editor-ui vite aliases', () => {
const aliases = editorUiAliases(editorUiDir, packagesDir);
const editorUiPaths = pathsByPackage(join(editorUiDir, 'tsconfig.json'));
// This is a real failure. For months, four packages used `src` for the typecheck and `dist`
// for the build.
it.each([...sourcePackages, ...modulePackages])(
'resolves $name to the same src as tsconfig does',
({ name, dir }) => {
const srcDir = resolve(packagesDir, dir, 'src');
const src = relative(repoRoot, srcDir);
expect(resolveSpecifier(`${name}/probe`, aliases)).toBe(`${src}/probe`);
expect(editorUiPaths.get(name)).toBe(srcDir);
},
);
it('aliases every source package editor-ui typechecks from src', () => {
const aliased = new Set([...sourcePackages, ...modulePackages].map(({ name }) => name));
const pathed = [...editorUiPaths.keys()]
// This is the browser stub of editor-ui. It is not a package that the frontend reads from
// source.
.filter((name) => name !== '@n8n/expression-runtime');
expect(pathed.filter((name) => !aliased.has(name))).toEqual([]);
});
it('agrees with the shared module tsconfig base', () => {
// If the two files disagree, the typecheck of a module uses a `src` that the editor never
// bundles.
const modulePaths = pathsByPackage(join(MODULE_TSCONFIG, 'tsconfig.frontend-module.json'));
for (const [name, srcDir] of modulePaths) {
expect({ name, srcDir }).toEqual({ name, srcDir: editorUiPaths.get(name) });
}
});
it('resolves @n8n/tournament from source', () => {
// `n8n-workflow` brings in this package. Nothing declares it, so it is not in
// `sourcePackages`. Its `dist` is CJS. The dev server gives a parse error for a named export.
// The build loses tree-shaking and adds approximately 397 kB.
expect(resolveSpecifier('@n8n/tournament', aliases)).toBe(
'packages/@n8n/tournament/src/index.ts',
);
expect(resolveSpecifier('@n8n/tournament/ast', aliases)).toBe(
'packages/@n8n/tournament/src/ast',
);
});
it('still applies the vendor rewrites the shared set drops', () => {
const rewrite = (specifier: string) => {
const matched = aliases.find(({ find }) =>
typeof find === 'string' ? specifier === find : find.test(specifier),
);
return matched ? specifier.replace(matched.find, matched.replacement) : 'unmatched';
};
expect(rewrite('stream')).toBe('stream-browserify');
expect(rewrite('lodash.camelCase')).toBe('lodash/camelCase');
});
it('shares only aliases that resolve to workspace source', () => {
// Each `packages/modules/*/frontend` vitest run uses this set. The `node_modules` of a module
// is not the `node_modules` of the shell. A rewrite to a bare specifier that only editor-ui
// declares fails there.
const shared = frontendAliases(packagesDir).map(({ replacement }) => replacement);
expect(shared.filter((replacement) => !replacement.startsWith(packagesDir))).toEqual([]);
});
it('resolves a package and its subpaths independently of entry order', () => {
// One open pattern `^@n8n/chat(.+)$` also matches `@n8n/chat-hub/…`.
expect(resolveSpecifier('@n8n/chat-hub/api', aliases)).toBe('packages/@n8n/chat-hub/src/api');
expect(resolveSpecifier('@n8n/chat-hub/api', [...aliases].reverse())).toBe(
'packages/@n8n/chat-hub/src/api',
);
});
});
@@ -0,0 +1,8 @@
import { vitestConfig } from '@n8n/vitest-config/frontend';
import { mergeConfig } from 'vitest/config';
import viteConfig from './vite.config.mjs';
// This file is separate from `vite.config.mts`, because `@n8n/vitest-config` resolves to its
// `dist`. Only `test` always has that `dist`, because turbo builds the dependencies before `test`.
export default mergeConfig(viteConfig, vitestConfig);
+15 -12
View File
@@ -2583,18 +2583,6 @@ importers:
specifier: 'catalog:'
version: 3.23.3(zod@3.25.76)
packages/@n8n/frontend-vite-config:
devDependencies:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
typescript:
specifier: catalog:typescript
version: 7.0.2
vite:
specifier: 'catalog:'
version: 8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
packages/@n8n/imap:
dependencies:
iconv-lite:
@@ -5508,6 +5496,18 @@ importers:
specifier: ^2.2.8
version: 2.2.8(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@6.0.2)
packages/frontend/@n8n/frontend-vite-config:
devDependencies:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
typescript:
specifier: catalog:typescript
version: 7.0.2
vite:
specifier: 'catalog:'
version: 8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
packages/frontend/@n8n/i18n:
dependencies:
n8n-workflow:
@@ -6157,6 +6157,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../@n8n/eslint-config
'@n8n/frontend-vite-config':
specifier: workspace:*
version: link:../@n8n/frontend-vite-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../testing/janitor