chore(editor): Derive dev REST base URL from N8N_PORT (no-changelog) (#35770)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Csaba Tuncsik 2026-08-26 03:59:32 +00:00 committed by GitHub
parent 221ea41557
commit 48c42fa4b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 271 additions and 14 deletions

View File

@ -16,7 +16,7 @@ jobs:
smoke:
name: Dev-server smoke
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
timeout-minutes: 10
timeout-minutes: 15
permissions:
contents: read
@ -33,9 +33,18 @@ jobs:
- name: Install Browsers
run: pnpm turbo run install-browsers --filter=n8n-playwright
# Both steps run from repo root so PLAYWRIGHT_BROWSERS_PATH (relative)
# resolves correctly. cd-ing into the playwright package double-nests it.
- name: Run dev-server smoke spec (non-default ports)
# Runs FIRST, while nothing has ever listened on 5678, so a regression
# to the 5678 fallback fails with a connection error instead of being
# satisfied by a server leaked from the default run. The broker moves
# too, so it cannot squat on 5679 for the run below.
run: pnpm --filter=n8n-playwright test:dev-server-smoke:alt-port --reporter=list
- name: Run dev-server smoke spec
# Run from repo root so PLAYWRIGHT_BROWSERS_PATH (relative) resolves
# correctly. cd-ing into the playwright package double-nests it.
# Always run: a flake above must not cost the default-port signal.
if: ${{ !cancelled() }}
run: pnpm --filter=n8n-playwright test:dev-server-smoke --reporter=list
- name: Upload Failure Artifacts

View File

@ -235,6 +235,16 @@ does not exist: it prints a notice and exits with code 0.
Given the size of the code base and the number of modules, we recommend only watching the modules you're
actively working on.
The dev servers default to 5678 (backend) and 8080 (editor). Set `N8N_PORT` and
`N8N_EDITOR_PORT` to relocate them, for example to run a second instance beside
your main one. The editor derives its REST base URL from `N8N_PORT`, so pass it
to both commands:
```bash
N8N_PORT=5699 pnpm dev:be
N8N_PORT=5699 N8N_EDITOR_PORT=8082 pnpm dev:fe:editor
```
### Basic Development Workflow Example (most used within n8n)
If you're working on API and FE, a lot of team members run the following steps:

View File

@ -18,7 +18,7 @@
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache",
"format": "biome format --write . && prettier --write . --ignore-path ../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore",
"serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 --strictPort dev",
"serve": "vite dev",
"test": "vitest run",
"test:changed": "janitor test-scoped",
"test:dev": "vitest --silent=false"

View File

@ -16,11 +16,16 @@ 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';
import { DEFAULT_BACKEND_PORT, devServerPlugin, readDevPort } from './vite/dev-ports.mjs';
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/';
const { NODE_ENV } = process.env;
// Only reachable through the dev server (see the `ctx.server` guard below),
// which `devServerPlugin` has already validated by the time it runs.
const devBackendPort = readDevPort(process.env, 'N8N_PORT', DEFAULT_BACKEND_PORT);
const browsers = browserslist.loadConfig({ path: process.cwd() });
const packagesDir = resolve(__dirname, '..', '..');
@ -34,6 +39,7 @@ const alias = editorUiAliases(__dirname, packagesDir);
const { RELEASE: release } = process.env;
const plugins: UserConfig['plugins'] = [
devServerPlugin(process.env),
nodePopularityPlugin(),
lucideIconsPlugin(),
icons({
@ -96,7 +102,7 @@ const plugins: UserConfig['plugins'] = [
return ctx.server
? html
.replace('%CONFIG_TAGS%', '')
.replaceAll('/{{BASE_PATH}}', `//localhost:${process.env.N8N_PORT ?? '5678'}`)
.replaceAll('/{{BASE_PATH}}', `//localhost:${devBackendPort}`)
.replaceAll('/{{REST_ENDPOINT}}', '/rest')
: html;
},

View File

@ -0,0 +1,55 @@
import type { Plugin } from 'vite';
export const DEFAULT_BACKEND_PORT = 5678;
export const DEFAULT_EDITOR_PORT = 8080;
// `||` not `??`: an explicitly empty env var must fall back like an unset one.
export const readDevPort = (env: NodeJS.ProcessEnv, name: string, fallback: number): number =>
Number(env[name] || fallback);
const assertDevPort = (env: NodeJS.ProcessEnv, name: string, fallback: number): number => {
const port = readDevPort(env, name, fallback);
// Vite silently boots on its own default 5173 when handed NaN, so an
// unvalidated typo surfaces as a connection timeout minutes later.
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${name} must be a port number, got: ${env[name]}`);
}
return port;
};
/**
* N8N_PORT is the backend's own listen-port var. It moves both the injected
* BASE_PATH and the REST base URL, so the pair relocates a whole dev instance
* next to the default one.
*/
export const resolveDevPorts = (env: NodeJS.ProcessEnv) => ({
backendPort: assertDevPort(env, 'N8N_PORT', DEFAULT_BACKEND_PORT),
editorPort: assertDevPort(env, 'N8N_EDITOR_PORT', DEFAULT_EDITOR_PORT),
});
/**
* Dev-server topology, kept out of the `serve` script so the env vars work on
* Windows too (`cross-env` cannot expand `${N8N_PORT:-5678}`).
*
* `apply: 'serve'` skips builds; the mode/isPreview check skips `vite preview`
* (serve/production) and vitest (serve/test). Builds must leave
* VUE_APP_URL_BASE_API unset so the app falls back to window.BASE_PATH.
*/
export const devServerPlugin = (env: NodeJS.ProcessEnv): Plugin => ({
name: 'n8n-dev-server-topology',
apply: 'serve',
config: (_config, { mode, isPreview }) => {
if (mode !== 'development' || isPreview) return;
const { backendPort, editorPort } = resolveDevPorts(env);
// Vite's loadEnv reads VUE_* straight out of process.env and runs after
// this hook, so the assignment still reaches import.meta.env.
// Truthiness, not ??=: an explicitly empty value counts as unset.
if (!env.VUE_APP_URL_BASE_API) {
env.VUE_APP_URL_BASE_API = `http://localhost:${backendPort}/`;
}
return { server: { host: '0.0.0.0', port: editorPort, strictPort: true } };
},
});

View File

@ -0,0 +1,88 @@
import type { ConfigEnv, UserConfig } from 'vite';
import { describe, expect, it } from 'vitest';
import { devServerPlugin, resolveDevPorts } from './dev-ports.mjs';
const runConfigHook = (env: NodeJS.ProcessEnv, configEnv: ConfigEnv) => {
const plugin = devServerPlugin(env);
const hook = plugin.config as (c: UserConfig, e: ConfigEnv) => UserConfig | undefined;
return hook({}, configEnv);
};
const DEV: ConfigEnv = { command: 'serve', mode: 'development' };
const VITEST: ConfigEnv = { command: 'serve', mode: 'test' };
const PREVIEW: ConfigEnv = { command: 'serve', mode: 'production', isPreview: true };
describe('resolveDevPorts', () => {
it.each([
['unset', {}, 5678, 8080],
['empty', { N8N_PORT: '', N8N_EDITOR_PORT: '' }, 5678, 8080],
['set', { N8N_PORT: '5699', N8N_EDITOR_PORT: '8082' }, 5699, 8082],
])('resolves %s ports', (_label, env, backendPort, editorPort) => {
expect(resolveDevPorts(env)).toEqual({ backendPort, editorPort });
});
it.each(['abc', '0', '70000', '5699.5', '-1'])('rejects the malformed port %s', (value) => {
expect(() => resolveDevPorts({ N8N_EDITOR_PORT: value })).toThrow(
`N8N_EDITOR_PORT must be a port number, got: ${value}`,
);
});
it('names the offending variable', () => {
expect(() => resolveDevPorts({ N8N_PORT: 'abc' })).toThrow(/^N8N_PORT must be/);
});
});
describe('devServerPlugin', () => {
it('never applies to builds', () => {
expect(devServerPlugin({}).apply).toBe('serve');
});
it('binds the editor port and derives the REST base from N8N_PORT', () => {
const env: NodeJS.ProcessEnv = { N8N_PORT: '5699', N8N_EDITOR_PORT: '8082' };
expect(runConfigHook(env, DEV)).toEqual({
server: { host: '0.0.0.0', port: 8082, strictPort: true },
});
expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5699/');
});
it('falls back to the default ports', () => {
const env: NodeJS.ProcessEnv = {};
expect(runConfigHook(env, DEV)?.server).toMatchObject({ port: 8080 });
expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5678/');
});
it('keeps an explicitly set REST base URL', () => {
const env: NodeJS.ProcessEnv = { VUE_APP_URL_BASE_API: 'https://tunnel.example/' };
runConfigHook(env, DEV);
expect(env.VUE_APP_URL_BASE_API).toBe('https://tunnel.example/');
});
it('treats an empty REST base URL as unset', () => {
const env: NodeJS.ProcessEnv = { VUE_APP_URL_BASE_API: '' };
runConfigHook(env, DEV);
expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5678/');
});
// A build that inherits VUE_APP_URL_BASE_API would ship a localhost REST base
// instead of falling back to window.BASE_PATH, and vitest must not bind a port.
it.each([
['vitest', VITEST],
['preview', PREVIEW],
])('stays out of the way of %s', (_label, configEnv) => {
const env: NodeJS.ProcessEnv = { N8N_EDITOR_PORT: '8082' };
expect(runConfigHook(env, configEnv)).toBeUndefined();
expect(env.VUE_APP_URL_BASE_API).toBeUndefined();
});
it('does not validate ports outside the dev server', () => {
expect(() => runConfigHook({ N8N_EDITOR_PORT: 'abc' }, VITEST)).not.toThrow();
});
});

View File

@ -108,7 +108,8 @@ situations where `test:local`'s defaults aren't enough:
Pin a port with `N8N_BASE_URL=http://localhost:5680 …` when you need a
stable URL for browser inspection.
- **Throwaway `N8N_USER_FOLDER`** under the OS temp dir (cleaned up on exit).
Its `database.sqlite` is fully isolated from your local `~/.n8n` install.
n8n creates `.n8n/` (sqlite DB, encryption key) inside it, fully isolated
from your local `~/.n8n` install.
- **Container-only tests included.** `@capability:*` / `@licensed` /
`@db:reset` tests are picked up by the local `e2e` project. Their fixtures
are responsible for detecting the missing container and skipping or falling

View File

@ -1,9 +1,22 @@
import { execSync } from 'child_process';
import { getBackendUrl, getFrontendUrl } from './utils/url-helper';
function globalTeardown() {
console.log('🧹 Starting global teardown...');
const ports = [5678, 8080];
// Ports this run actually used (they may differ from the defaults, e.g. the
// alt-port dev-server smoke runs the backend on 5699). A portless URL means
// 80/443, never a dev server this run started. Killing nothing beats falling
// back to the defaults, which are most likely the developer's own servers.
const ports = [
...new Set(
[getBackendUrl(), getFrontendUrl()]
.filter((url): url is string => !!url && URL.canParse(url))
.map((url) => new URL(url).port)
.filter(Boolean),
),
];
for (const port of ports) {
try {

View File

@ -4,6 +4,7 @@
"scripts": {
"dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e",
"test:dev-server-smoke": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=dev-server-smoke",
"test:dev-server-smoke:alt-port": "N8N_BASE_URL=http://localhost:5699 N8N_EDITOR_URL=http://localhost:8082 N8N_TEST_ENV='{\"N8N_RUNNERS_BROKER_PORT\":\"5701\"}' RESET_E2E_DB=true playwright test --project=dev-server-smoke",
"test:all": "playwright test",
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e",
"test:local:isolated": "node scripts/run-local-isolated.mjs",

View File

@ -15,7 +15,13 @@ const IS_DEV = !!process.env.N8N_EDITOR_URL;
const MACBOOK_WINDOW_SIZE = { width: 1536, height: 960 };
const USER_FOLDER = path.join(os.tmpdir(), `n8n-main-${Date.now()}`);
// Sticky across processes: workers re-import this config, and Date.now()
// would give each of them a different path than the one the webServer got.
// Only workers honor the inherited value; the main process always mints a
// fresh folder so a stale shell-exported variable can't leak in.
const USER_FOLDER =
(process.env.TEST_WORKER_INDEX !== undefined ? process.env.N8N_TEST_USER_FOLDER : undefined) ??
path.join(os.tmpdir(), `n8n-main-${Date.now()}`);
// Helper to get environment variables from N8N_TEST_ENV
const getTestEnv = () => {
@ -52,6 +58,16 @@ const webServer: PlaywrightTestConfig['webServer'] = [];
const SKIP_WEB_SERVER = process.env.PLAYWRIGHT_SKIP_WEBSERVER === 'true';
if (BACKEND_URL && !SKIP_WEB_SERVER) {
// Expose the user folder to specs (the dev-server smoke asserts the sqlite DB
// is created inside it). Only meaningful when this run starts the backend
// itself; a reused server writes to wherever it was already pointed.
// N8N_TEST_ENV may override N8N_USER_FOLDER (spread below wins), so export
// the folder the backend will actually use.
if (IS_DEV) {
const envUserFolder: unknown = getTestEnv().N8N_USER_FOLDER;
process.env.N8N_TEST_USER_FOLDER =
typeof envUserFolder === 'string' ? envUserFolder : USER_FOLDER;
}
webServer.push({
command: 'cd .. && pnpm start',
url: `${BACKEND_URL}/favicon.ico`,
@ -81,7 +97,10 @@ if (IS_DEV && FRONTEND_URL) {
reuseExistingServer: IS_DEV ? false : true,
env: {
E2E_TESTS: 'true',
N8N_PORT: getPortFromUrl(FRONTEND_URL),
// The dev frontend derives its backend URL (BASE_PATH + REST base)
// from N8N_PORT, so pass the backend's port, not the frontend's.
...(BACKEND_URL ? { N8N_PORT: getPortFromUrl(BACKEND_URL) } : {}),
N8N_EDITOR_PORT: getPortFromUrl(FRONTEND_URL),
...getTestEnv(),
},
});

View File

@ -9,8 +9,8 @@
* `5678`/`5679`. Pin a port with `N8N_BASE_URL=http://localhost:5680 …`
* when you need a stable URL for browser inspection.
* - **Throwaway `N8N_USER_FOLDER`** under the OS temp dir (cleaned up on
* exit). Its `database.sqlite` is fully isolated from your local
* `~/.n8n` install.
* exit). n8n creates `.n8n/` (sqlite DB, encryption key) inside it, fully
* isolated from your local `~/.n8n` install.
* - **Self-managed n8n process** with a real readiness check against
* `/rest/e2e/reset` (Playwright's default `webServer` favicon check is
* racy with slower module startups). Sets `PLAYWRIGHT_SKIP_WEBSERVER=true`
@ -76,8 +76,8 @@ if (process.env.N8N_BASE_URL) {
backendUrl = `http://localhost:${port}`;
}
// Throwaway `~/.n8n`-equivalent. SQLite lives at `${userFolder}/database.sqlite`,
// so this also isolates the DB from any local n8n install.
// Throwaway home-dir stand-in. n8n creates `.n8n/` (sqlite DB, encryption
// key) inside it, so this also isolates the DB from any local n8n install.
const userFolder = mkdtempSync(path.join(os.tmpdir(), 'n8n-test-isolated-'));
// Caller-supplied n8n env (same convention as `pnpm test:local`).

View File

@ -1,6 +1,6 @@
import type { ConsoleMessage, Page } from '@playwright/test';
import { test } from '../../fixtures/base';
import { expect, test } from '../../fixtures/base';
/**
* Smoke tests that catch app-wide regressions where the editor-ui fails to boot —
@ -125,6 +125,22 @@ test.describe(
});
});
// The dev frontend derives its REST base from N8N_PORT. "Boots cleanly" is
// not enough on its own: on the default ports a hardcoded 5678 would be
// indistinguishable from correct derivation, so assert the origin directly.
test('REST calls go to the configured backend', async ({ n8n }) => {
const restOrigins = new Set<string>();
n8n.page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith('/rest/')) restOrigins.add(url.origin);
});
await n8n.start.fromHome();
const backendOrigin = new URL(process.env.N8N_BASE_URL!).origin;
expect([...restOrigins]).toEqual([backendOrigin]);
});
test('blank canvas boots cleanly', async ({ n8n }) => {
await navigateAndAssertNoErrors(n8n.page, 'blank-canvas', async () => {
await n8n.start.fromBlankCanvas();

View File

@ -0,0 +1,39 @@
import { existsSync } from 'fs';
import path from 'path';
import { expect, test } from '../../fixtures/base';
/**
* Asserts that the backend started by this run respects N8N_USER_FOLDER: the
* sqlite database must be created inside the run's generated user folder (see
* USER_FOLDER in playwright.config.ts), not in the default ~/.n8n. Guards the
* smoke suite against silently polluting the developer's own install.
*
* Playwright polls the backend's favicon and global-setup resets its database
* before any test runs, so the file exists by now without visiting the app.
*/
test.describe(
'Dev-server user folder',
{
annotation: [
{ type: 'owner', description: 'Catalysts' },
{
type: 'description',
description:
'Verifies the dev backend creates its sqlite database inside the N8N_USER_FOLDER configured for the run.',
},
],
},
() => {
test('sqlite database is created in the configured user folder', () => {
// The smoke scripts always manage the webServer, so a missing value
// means the config did not export the user folder: fail, don't skip.
const userFolder = process.env.N8N_TEST_USER_FOLDER;
expect(userFolder, 'playwright.config.ts must export N8N_TEST_USER_FOLDER').toBeTruthy();
// n8n creates `.n8n/` inside N8N_USER_FOLDER (see getN8nFolder in @n8n/config).
const dbPath = path.join(userFolder!, '.n8n', 'database.sqlite');
expect(existsSync(dbPath), `expected sqlite DB at ${dbPath}`).toBe(true);
});
},
);