From 3beabfb66f27de4091a33b65d2a014641da4fb32 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Mon, 17 Aug 2026 10:18:01 +0000 Subject: [PATCH] build(editor): Resolve frontend workspace packages from source in Vite (no-changelog) (#36131) Co-authored-by: multica-agent --- .github/workflows/ci-pull-requests.yml | 1 + packages/@n8n/frontend-vite-config/aliases.ts | 52 ------ .../@n8n/frontend-vite-config/biome.jsonc | 4 - packages/@n8n/frontend-vite-config/index.ts | 14 -- .../frontend-vite-config/source-packages.ts | 68 -------- .../frontend-vite-config/tsconfig.build.json | 10 -- .../@n8n/frontend-vite-config/tsconfig.json | 10 -- .../@n8n/frontend-vite-config/biome.jsonc | 4 + .../@n8n/frontend-vite-config/index.ts | 131 ++++++++++++++ .../@n8n/frontend-vite-config/package.json | 15 +- .../@n8n/frontend-vite-config/tsconfig.json | 11 ++ packages/frontend/editor-ui/package.json | 1 + packages/frontend/editor-ui/vite.config.mts | 160 +++++------------- packages/frontend/editor-ui/vite/aliases.mts | 25 +++ .../frontend/editor-ui/vite/aliases.test.ts | 142 ++++++++++++++++ packages/frontend/editor-ui/vitest.config.mts | 8 + pnpm-lock.yaml | 27 +-- 17 files changed, 383 insertions(+), 300 deletions(-) delete mode 100644 packages/@n8n/frontend-vite-config/aliases.ts delete mode 100644 packages/@n8n/frontend-vite-config/biome.jsonc delete mode 100644 packages/@n8n/frontend-vite-config/index.ts delete mode 100644 packages/@n8n/frontend-vite-config/source-packages.ts delete mode 100644 packages/@n8n/frontend-vite-config/tsconfig.build.json delete mode 100644 packages/@n8n/frontend-vite-config/tsconfig.json create mode 100644 packages/frontend/@n8n/frontend-vite-config/biome.jsonc create mode 100644 packages/frontend/@n8n/frontend-vite-config/index.ts rename packages/{ => frontend}/@n8n/frontend-vite-config/package.json (54%) create mode 100644 packages/frontend/@n8n/frontend-vite-config/tsconfig.json create mode 100644 packages/frontend/editor-ui/vite/aliases.mts create mode 100644 packages/frontend/editor-ui/vite/aliases.test.ts create mode 100644 packages/frontend/editor-ui/vitest.config.mts diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index ac75a0dcc02..d4a936c1457 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -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 diff --git a/packages/@n8n/frontend-vite-config/aliases.ts b/packages/@n8n/frontend-vite-config/aliases.ts deleted file mode 100644 index 5d76d402c71..00000000000 --- a/packages/@n8n/frontend-vite-config/aliases.ts +++ /dev/null @@ -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(), -]; diff --git a/packages/@n8n/frontend-vite-config/biome.jsonc b/packages/@n8n/frontend-vite-config/biome.jsonc deleted file mode 100644 index 0c0bdac000d..00000000000 --- a/packages/@n8n/frontend-vite-config/biome.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "../../../node_modules/@biomejs/biome/configuration_schema.json", - "extends": ["../../../biome.jsonc"] -} diff --git a/packages/@n8n/frontend-vite-config/index.ts b/packages/@n8n/frontend-vite-config/index.ts deleted file mode 100644 index 66855515b0f..00000000000 --- a/packages/@n8n/frontend-vite-config/index.ts +++ /dev/null @@ -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'; diff --git a/packages/@n8n/frontend-vite-config/source-packages.ts b/packages/@n8n/frontend-vite-config/source-packages.ts deleted file mode 100644 index 4181b78c138..00000000000 --- a/packages/@n8n/frontend-vite-config/source-packages.ts +++ /dev/null @@ -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//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); diff --git a/packages/@n8n/frontend-vite-config/tsconfig.build.json b/packages/@n8n/frontend-vite-config/tsconfig.build.json deleted file mode 100644 index 3b71de14453..00000000000 --- a/packages/@n8n/frontend-vite-config/tsconfig.build.json +++ /dev/null @@ -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"] -} diff --git a/packages/@n8n/frontend-vite-config/tsconfig.json b/packages/@n8n/frontend-vite-config/tsconfig.json deleted file mode 100644 index f17a2ac8151..00000000000 --- a/packages/@n8n/frontend-vite-config/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@n8n/typescript-config/tsconfig.common.go.json", - "compilerOptions": { - "types": ["node"], - "module": "ESNext", - "moduleResolution": "bundler" - }, - "include": ["**/*.ts"], - "exclude": ["dist"] -} diff --git a/packages/frontend/@n8n/frontend-vite-config/biome.jsonc b/packages/frontend/@n8n/frontend-vite-config/biome.jsonc new file mode 100644 index 00000000000..f882da95a58 --- /dev/null +++ b/packages/frontend/@n8n/frontend-vite-config/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../../../../biome.jsonc"] +} diff --git a/packages/frontend/@n8n/frontend-vite-config/index.ts b/packages/frontend/@n8n/frontend-vite-config/index.ts new file mode 100644 index 00000000000..ed745df623b --- /dev/null +++ b/packages/frontend/@n8n/frontend-vite-config/index.ts @@ -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//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(), +]; diff --git a/packages/@n8n/frontend-vite-config/package.json b/packages/frontend/@n8n/frontend-vite-config/package.json similarity index 54% rename from packages/@n8n/frontend-vite-config/package.json rename to packages/frontend/@n8n/frontend-vite-config/package.json index f75ffbe171f..606198c4bed 100644 --- a/packages/@n8n/frontend-vite-config/package.json +++ b/packages/frontend/@n8n/frontend-vite-config/package.json @@ -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" } diff --git a/packages/frontend/@n8n/frontend-vite-config/tsconfig.json b/packages/frontend/@n8n/frontend-vite-config/tsconfig.json new file mode 100644 index 00000000000..74c7155a9d5 --- /dev/null +++ b/packages/frontend/@n8n/frontend-vite-config/tsconfig.json @@ -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"] +} diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index d66040e96f1..500e7ed99ba 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -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:*", diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index 208aa6e230b..79722c8d9a4 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -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', + }, +}); diff --git a/packages/frontend/editor-ui/vite/aliases.mts b/packages/frontend/editor-ui/vite/aliases.mts new file mode 100644 index 00000000000..e81e18fa9a4 --- /dev/null +++ b/packages/frontend/editor-ui/vite/aliases.mts @@ -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), +]; diff --git a/packages/frontend/editor-ui/vite/aliases.test.ts b/packages/frontend/editor-ui/vite/aliases.test.ts new file mode 100644 index 00000000000..1850e90f25c --- /dev/null +++ b/packages/frontend/editor-ui/vite/aliases.test.ts @@ -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 } }; + +/** 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(); + + 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', + ); + }); +}); diff --git a/packages/frontend/editor-ui/vitest.config.mts b/packages/frontend/editor-ui/vitest.config.mts new file mode 100644 index 00000000000..f2afe1d10ef --- /dev/null +++ b/packages/frontend/editor-ui/vitest.config.mts @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9826063b1f8..506d2381970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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