fix(core): Inline config.js to index.html to prevent CF from caching it (#18945)

This commit is contained in:
Tomi Turtiainen
2025-09-02 09:58:12 +03:00
committed by GitHub
parent 35e4772210
commit 17ce65a529
9 changed files with 352 additions and 31 deletions

View File

@@ -16,7 +16,7 @@ import { z } from 'zod';
import { ActiveExecutions } from '@/active-executions';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import { EDITOR_UI_DIST_DIR } from '@/constants';
import { EDITOR_UI_DIST_DIR, N8N_VERSION } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { EventService } from '@/events/event.service';
@@ -117,6 +117,31 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
await this.exitSuccessFully();
}
/**
* Generates meta tags with base64-encoded configuration values
* for REST endpoint path and Sentry config.
*/
private generateConfigTags() {
const frontendSentryConfig = JSON.stringify({
dsn: this.globalConfig.sentry.frontendDsn,
environment: process.env.ENVIRONMENT || 'development',
serverName: process.env.DEPLOYMENT_NAME,
release: `n8n@${N8N_VERSION}`,
});
const b64Encode = (value: string) => Buffer.from(value).toString('base64');
// Base64 encode the configuration values
const restEndpointEncoded = b64Encode(this.globalConfig.endpoints.rest);
const sentryConfigEncoded = b64Encode(frontendSentryConfig);
const configMetaTags = [
`<meta name="n8n:config:rest-endpoint" content="${restEndpointEncoded}">`,
`<meta name="n8n:config:sentry" content="${sentryConfigEncoded}">`,
].join('');
return configMetaTags;
}
private async generateStaticAssets() {
// Read the index file and replace the path placeholder
const n8nPath = this.globalConfig.path;
@@ -138,6 +163,7 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
await mkdir(path.dirname(destFile), { recursive: true });
const streams = [
createReadStream(filePath, 'utf-8'),
replaceStream('%CONFIG_TAGS%', this.generateConfigTags(), { ignoreCase: false }),
replaceStream('/{{BASE_PATH}}/', n8nPath, { ignoreCase: false }),
replaceStream('/%7B%7BBASE_PATH%7D%7D/', n8nPath, { ignoreCase: false }),
replaceStream('/%257B%257BBASE_PATH%257D%257D/', n8nPath, { ignoreCase: false }),

View File

@@ -1,4 +1,3 @@
import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests, N8N_VERSION } from '@/constants';
import { inDevelopment, inProduction } from '@n8n/backend-common';
import { SecurityConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
@@ -15,6 +14,7 @@ import { resolve } from 'path';
import { AbstractServer } from '@/abstract-server';
import config from '@/config';
import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants';
import { ControllerRegistry } from '@/controller.registry';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
@@ -274,22 +274,6 @@ export class Server extends AbstractServer {
`/${this.restEndpoint}/settings`,
ResponseHelper.send(async () => frontendService.getSettings()),
);
this.app.get(`/${this.restEndpoint}/config.js`, (_req, res) => {
const frontendSentryConfig = JSON.stringify({
dsn: this.globalConfig.sentry.frontendDsn,
environment: process.env.ENVIRONMENT || 'development',
serverName: process.env.DEPLOYMENT_NAME,
release: `n8n@${N8N_VERSION}`,
});
const frontendConfig = [
`window.REST_ENDPOINT = '${this.globalConfig.endpoints.rest}';`,
`window.sentry = ${frontendSentryConfig};`,
].join('\n');
res.type('application/javascript');
res.send(frontendConfig);
});
}
// ----------------------------------------

View File

@@ -0,0 +1,261 @@
import { getConfigFromMetaTag, getAndParseConfigFromMetaTag } from '../metaTagConfig';
describe('metaTagConfig', () => {
beforeEach(() => {
document.head.innerHTML = '';
vi.clearAllMocks();
});
/**
* Helper function to create and insert a meta tag into the document head
*/
function createMetaTag(configName: string, content?: string): void {
const metaTag = document.createElement('meta');
metaTag.setAttribute('name', `n8n:config:${configName}`);
if (content !== undefined) {
metaTag.setAttribute('content', content);
}
document.head.appendChild(metaTag);
}
/**
* Helper function to create a meta tag with base64-encoded content
*/
function createMetaTagWithBase64Content(configName: string, value: string): void {
const base64Value = btoa(value);
createMetaTag(configName, base64Value);
}
/**
* Helper function to create a meta tag with JSON content (base64-encoded)
*/
function createMetaTagWithJsonContent(configName: string, value: unknown): void {
const jsonString = JSON.stringify(value);
createMetaTagWithBase64Content(configName, jsonString);
}
describe('getConfigFromMetaTag', () => {
it('should return null when meta tag does not exist', () => {
const result = getConfigFromMetaTag('testConfig');
expect(result).toBeNull();
});
it('should return null when meta tag exists but has no content attribute', () => {
createMetaTag('testConfig');
const result = getConfigFromMetaTag('testConfig');
expect(result).toBeNull();
});
it('should return null when meta tag has empty content attribute', () => {
createMetaTag('testConfig', '');
const result = getConfigFromMetaTag('testConfig');
expect(result).toBeNull();
});
it('should decode and return base64 content successfully', () => {
const originalValue = 'Hello World';
createMetaTagWithBase64Content('testConfig', originalValue);
const result = getConfigFromMetaTag('testConfig');
expect(result).toBe(originalValue);
});
it('should handle complex string content correctly', () => {
const originalValue = 'This is a test with special chars: !@#$%^&*()';
createMetaTagWithBase64Content('complexConfig', originalValue);
const result = getConfigFromMetaTag('complexConfig');
expect(result).toBe(originalValue);
});
it('should return null and log warning when base64 decoding fails', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMetaTag('invalidConfig', 'invalid-base64!!!');
const result = getConfigFromMetaTag('invalidConfig');
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to read n8n config for "n8n:config:invalidConfig":',
expect.any(Error),
);
consoleSpy.mockRestore();
});
it.each([
{ name: 'config1', value: 'value1' },
{ name: 'config2', value: 'value2' },
{ name: 'special-config', value: 'special-value' },
])('should handle different config names correctly: $name', ({ name, value }) => {
createMetaTagWithBase64Content(name, value);
const result = getConfigFromMetaTag(name);
expect(result).toBe(value);
});
});
describe('getAndParseConfigFromMetaTag', () => {
it('should return null when meta tag does not exist', () => {
const result = getAndParseConfigFromMetaTag('nonExistentConfig');
expect(result).toBeNull();
});
it('should return null when getConfigFromMetaTag returns null', () => {
createMetaTag('emptyConfig');
const result = getAndParseConfigFromMetaTag('emptyConfig');
expect(result).toBeNull();
});
it('should parse and return valid JSON object', () => {
const originalObject = {
key1: 'value1',
key2: 42,
key3: true,
nested: { prop: 'nestedValue' },
};
createMetaTagWithJsonContent('jsonConfig', originalObject);
const result = getAndParseConfigFromMetaTag<typeof originalObject>('jsonConfig');
expect(result).toEqual(originalObject);
});
it('should parse and return valid JSON array', () => {
const originalArray = [1, 2, 3, { name: 'test' }];
createMetaTagWithJsonContent('arrayConfig', originalArray);
const result = getAndParseConfigFromMetaTag<typeof originalArray>('arrayConfig');
expect(result).toEqual(originalArray);
});
it('should parse and return primitive JSON values', () => {
const testCases = [
{ value: 'simple string', name: 'stringConfig' },
{ value: 42, name: 'numberConfig' },
{ value: true, name: 'booleanConfig' },
{ value: null, name: 'nullConfig' },
];
testCases.forEach((testCase) => {
createMetaTagWithJsonContent(testCase.name, testCase.value);
const result = getAndParseConfigFromMetaTag(testCase.name);
expect(result).toEqual(testCase.value);
});
});
it('should return null and log warning when JSON parsing fails', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const invalidJson = 'this is not json';
createMetaTagWithBase64Content('invalidJsonConfig', invalidJson);
const result = getAndParseConfigFromMetaTag('invalidJsonConfig');
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to parse n8n config for "n8n:config:invalidJsonConfig":',
expect.any(Error),
);
consoleSpy.mockRestore();
});
it('should handle complex nested objects with type safety', () => {
interface TestConfig {
api: {
url: string;
version: number;
features: string[];
};
user: {
id: number;
permissions: Record<string, boolean>;
};
}
const originalConfig: TestConfig = {
api: {
url: 'https://api.example.com',
version: 2,
features: ['feature1', 'feature2'],
},
user: {
id: 123,
permissions: {
read: true,
write: false,
admin: false,
},
},
};
createMetaTagWithJsonContent('complexConfig', originalConfig);
const result = getAndParseConfigFromMetaTag<TestConfig>('complexConfig');
expect(result).toEqual(originalConfig);
assert(result);
// Type assertions to ensure type safety works
expect(result.api.url).toBe('https://api.example.com');
expect(result.user.permissions.read).toBe(true);
expect(result.api.features).toHaveLength(2);
});
it('should handle empty JSON objects and arrays', () => {
const testCases = [
{ value: {}, name: 'emptyObject' },
{ value: [], name: 'emptyArray' },
];
testCases.forEach((testCase) => {
createMetaTagWithJsonContent(testCase.name, testCase.value);
const result = getAndParseConfigFromMetaTag(testCase.name);
expect(result).toEqual(testCase.value);
});
});
});
describe('integration tests', () => {
it('should handle multiple config retrieval operations', () => {
const configs = {
stringConfig: 'test string',
objectConfig: { key: 'value', num: 42 },
arrayConfig: [1, 2, 3],
};
Object.entries(configs).forEach(([name, value]) => {
if (typeof value === 'string') {
createMetaTagWithBase64Content(name, value);
} else {
createMetaTagWithJsonContent(name, value);
}
});
const stringResult = getConfigFromMetaTag('stringConfig');
expect(stringResult).toBe('test string');
const objectResult = getAndParseConfigFromMetaTag('objectConfig');
expect(objectResult).toEqual({ key: 'value', num: 42 });
const arrayResult = getAndParseConfigFromMetaTag('arrayConfig');
expect(arrayResult).toEqual([1, 2, 3]);
});
it('should handle edge case of config name with special characters', () => {
const configName = 'config-with-dashes_and_underscores.123';
const configValue = { test: true };
createMetaTagWithJsonContent(configName, configValue);
const result = getAndParseConfigFromMetaTag(configName);
expect(result).toEqual(configValue);
});
});
});

View File

@@ -0,0 +1,46 @@
function getTagName(configName: string): string {
return `n8n:config:${configName}`;
}
/**
* Utility function to read and decode base64-encoded configuration values from meta tags
*/
export function getConfigFromMetaTag(configName: string): string | null {
const tagName = getTagName(configName);
try {
const metaTag = document.querySelector(`meta[name="${tagName}"]`);
if (!metaTag) {
return null;
}
const encodedContent = metaTag.getAttribute('content');
if (!encodedContent) {
return null;
}
// Decode base64 content
const content = atob(encodedContent);
return content;
} catch (error) {
console.warn(`Failed to read n8n config for "${tagName}":`, error);
return null;
}
}
/**
* Utility function to read and parse configuration values from meta tags
*/
export function getAndParseConfigFromMetaTag<T>(configName: string): T | null {
const config = getConfigFromMetaTag(configName);
if (!config) {
return null;
}
try {
return JSON.parse(config) as T;
} catch (error) {
console.warn(`Failed to parse n8n config for "${getTagName(configName)}":`, error);
return null;
}
}

View File

@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { STORES } from './constants';
import { getConfigFromMetaTag } from './metaTagConfig';
const { VUE_APP_URL_BASE_API } = import.meta.env;
@@ -36,10 +37,7 @@ export type RootStoreState = {
export const useRootStore = defineStore(STORES.ROOT, () => {
const state = ref<RootStoreState>({
baseUrl: VUE_APP_URL_BASE_API ?? window.BASE_PATH,
restEndpoint:
!window.REST_ENDPOINT || window.REST_ENDPOINT === '{{REST_ENDPOINT}}'
? 'rest'
: window.REST_ENDPOINT,
restEndpoint: getConfigFromMetaTag('rest-endpoint') ?? 'rest',
defaultLocale: 'en',
endpointForm: 'form',
endpointFormTest: 'form-test',

View File

@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
%CONFIG_SCRIPT%
%CONFIG_TAGS%
<link rel="stylesheet" href="/{{BASE_PATH}}/static/prefers-color-scheme.css">
<script src="/{{BASE_PATH}}/static/base-path.js" type="text/javascript"></script>
<script src="/{{BASE_PATH}}/static/posthog.init.js" type="text/javascript"></script>

View File

@@ -2,6 +2,7 @@ import type { Plugin } from 'vue';
import { AxiosError } from 'axios';
import { ResponseError } from '@n8n/rest-api-client';
import * as Sentry from '@sentry/vue';
import { getAndParseConfigFromMetaTag } from '@n8n/stores/metaTagConfig';
const ignoredErrors = [
{ instanceof: AxiosError },
@@ -14,6 +15,13 @@ const ignoredErrors = [
{ instanceof: Error, message: /ResizeObserver/ },
] as const;
type SentryConfig = {
dsn?: string;
environment?: string;
serverName?: string;
release?: string;
};
export function beforeSend(event: Sentry.ErrorEvent, { originalException }: Sentry.EventHint) {
if (
!originalException ||
@@ -42,11 +50,12 @@ export function beforeSend(event: Sentry.ErrorEvent, { originalException }: Sent
export const SentryPlugin: Plugin = {
install: (app) => {
if (!window.sentry?.dsn) {
const sentryConfig = getAndParseConfigFromMetaTag<SentryConfig>('sentry');
if (!sentryConfig?.dsn) {
return;
}
const { dsn, release, environment, serverName } = window.sentry;
const { dsn, release, environment, serverName } = sentryConfig;
Sentry.init({
app,

View File

@@ -21,7 +21,6 @@ declare global {
interface Window {
BASE_PATH: string;
REST_ENDPOINT: string;
sentry?: { dsn?: string; environment: string; release: string; serverName?: string };
n8nExternalHooks?: PartialDeep<ExternalHooks>;
preventNodeViewBeforeUnload?: boolean;
maxPinnedDataSize?: number;

View File

@@ -126,11 +126,9 @@ const plugins: UserConfig['plugins'] = [
{
name: 'Insert config script',
transformIndexHtml: (html, ctx) => {
const replacement = ctx.server
? '' // Skip when using Vite dev server
: '<script src="/{{BASE_PATH}}/{{REST_ENDPOINT}}/config.js"></script>';
return html.replace('%CONFIG_SCRIPT%', replacement);
// Skip config script when using Vite dev server. Otherwise the BE
// will replace it with the actual config script in cli/src/commands/start.ts.
return ctx.server ? html.replace('%CONFIG_SCRIPT%', '') : html;
},
},
// For sanitize-html