first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,25 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-wrapper-object-types': 'warn',
},
},
{
files: ['**/*.test.ts'],
rules: {
'n8n-local-rules/no-uncaught-json-parse': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/unbound-method': 'warn',
},
},
);
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
transform: {
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
},
};
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@n8n/backend-common",
"version": "1.11.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"@n8n/config": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/decorators": "workspace:*",
"@n8n/di": "workspace:*",
"callsites": "catalog:",
"flatted": "catalog:",
"n8n-workflow": "workspace:*",
"stream-json": "catalog:",
"picocolors": "catalog:",
"reflect-metadata": "catalog:",
"winston": "3.14.2",
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/stream-json": "1.7.8",
"@types/yargs-parser": "21.0.0",
"zod": "catalog:"
}
}
@@ -0,0 +1,117 @@
import { mock } from 'jest-mock-extended';
import z from 'zod';
import { CliParser } from '../cli-parser';
describe('parse', () => {
it('should parse `argv` without flags schema', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({ argv: ['node', 'script.js', 'arg1', 'arg2'] });
expect(result).toEqual({ flags: {}, args: ['arg1', 'arg2'] });
});
it('should parse `argv` with flags schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
verbose: z.boolean().optional(),
name: z.string().optional(),
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--verbose', '--name', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { verbose: true, name: 'test' },
args: ['arg1'],
});
});
it('should ignore flags not defined in schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string().optional(),
// ignored is absent
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--name', 'test', '--ignored', 'value', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: {
name: 'test',
// ignored is absent
},
args: ['arg1'],
});
});
it('should handle a numeric value for `--id` flag', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '--id', '123', 'arg1'],
flagsSchema: z.object({
id: z.string(),
}),
});
expect(result).toEqual({
flags: { id: '123' },
args: ['arg1'],
});
});
it('should handle positional arguments', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '123', 'true'],
});
expect(result.args).toEqual(['123', 'true']);
expect(typeof result.args[0]).toBe('string');
expect(typeof result.args[1]).toBe('string');
});
it('should handle required flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string(),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
it('should handle optional flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.optional(z.string()),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def.innerType._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
});
@@ -0,0 +1,63 @@
import { Service } from '@n8n/di';
import argvParser from 'yargs-parser';
import type { z } from 'zod';
import { Logger } from './logging';
type CliInput<Flags extends z.ZodRawShape> = {
argv: string[];
flagsSchema?: z.ZodObject<Flags>;
description?: string;
examples?: string[];
};
type ParsedArgs<Flags = Record<string, unknown>> = {
flags: Flags;
args: string[];
};
@Service()
export class CliParser {
constructor(private readonly logger: Logger) {}
parse<Flags extends z.ZodRawShape>(
input: CliInput<Flags>,
): ParsedArgs<z.infer<z.ZodObject<Flags>>> {
// eslint-disable-next-line id-denylist
const { _: rest, ...rawFlags } = argvParser(input.argv, { string: ['id'] });
let flags = {} as z.infer<z.ZodObject<Flags>>;
if (input.flagsSchema) {
for (const key in input.flagsSchema.shape) {
const flagSchema = input.flagsSchema.shape[key];
let schemaDef = flagSchema._def as z.ZodTypeDef & {
typeName: string;
innerType?: z.ZodType;
_alias?: string;
};
if (schemaDef.typeName === 'ZodOptional' && schemaDef.innerType) {
schemaDef = schemaDef.innerType._def as typeof schemaDef;
}
const alias = schemaDef._alias;
if (alias?.length && !(key in rawFlags) && rawFlags[alias]) {
rawFlags[key] = rawFlags[alias] as unknown;
}
}
flags = input.flagsSchema.parse(rawFlags);
}
const args = rest.map(String).slice(2);
this.logger.debug('Received CLI command', {
execPath: rest[0],
scriptPath: rest[1],
args,
flags,
});
return { flags, args };
}
}
@@ -0,0 +1,5 @@
const { NODE_ENV } = process.env;
export const inTest = NODE_ENV === 'test';
export const inProduction = NODE_ENV === 'production';
export const inDevelopment = !NODE_ENV || NODE_ENV === 'development';
+13
View File
@@ -0,0 +1,13 @@
export * from './license-state';
export type * from './types';
export { inDevelopment, inProduction, inTest } from './environment';
export { isObjectLiteral } from './utils/is-object-literal';
export { Logger } from './logging/logger';
export { ModuleRegistry } from './modules/module-registry';
export type { ModuleName } from './modules/modules.config';
export { ModulesConfig } from './modules/modules.config';
export { isContainedWithin, safeJoinPath } from './utils/path-util';
export { assertDir, exists } from './utils/fs';
export { parseFlatted } from './utils/parse-flatted';
export { CliParser } from './cli-parser';
@@ -0,0 +1,232 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import { LICENSE_FEATURES, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import type { FeatureReturnType, LicenseProvider } from './types';
class ProviderNotSetError extends UnexpectedError {
constructor() {
super('Cannot query license state because license provider has not been set');
}
}
@Service()
export class LicenseState {
licenseProvider: LicenseProvider | null = null;
setLicenseProvider(provider: LicenseProvider) {
this.licenseProvider = provider;
}
private assertProvider(): asserts this is { licenseProvider: LicenseProvider } {
if (!this.licenseProvider) throw new ProviderNotSetError();
}
// --------------------
// core queries
// --------------------
/*
* If the feature is a string. checks if the feature is licensed
* If the feature is an array of strings, it checks if any of the features are licensed
*/
isLicensed(feature: BooleanLicenseFeature | BooleanLicenseFeature[]) {
this.assertProvider();
if (typeof feature === 'string') return this.licenseProvider.isLicensed(feature);
for (const featureName of feature) {
if (this.licenseProvider.isLicensed(featureName)) {
return true;
}
}
return false;
}
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
this.assertProvider();
return this.licenseProvider.getValue(feature);
}
// --------------------
// booleans
// --------------------
isCustomRolesLicensed() {
return this.isLicensed(LICENSE_FEATURES.CUSTOM_ROLES);
}
isDynamicCredentialsLicensed() {
return this.isLicensed(LICENSE_FEATURES.DYNAMIC_CREDENTIALS);
}
isPersonalSpacePolicyLicensed() {
return this.isLicensed(LICENSE_FEATURES.PERSONAL_SPACE_POLICY);
}
isSharingLicensed() {
return this.isLicensed('feat:sharing');
}
isLogStreamingLicensed() {
return this.isLicensed('feat:logStreaming');
}
isLdapLicensed() {
return this.isLicensed('feat:ldap');
}
isSamlLicensed() {
return this.isLicensed('feat:saml');
}
isOidcLicensed() {
return this.isLicensed('feat:oidc');
}
isMFAEnforcementLicensed() {
return this.isLicensed('feat:mfaEnforcement');
}
isApiKeyScopesLicensed() {
return this.isLicensed('feat:apiKeyScopes');
}
isAiAssistantLicensed() {
return this.isLicensed('feat:aiAssistant');
}
isAskAiLicensed() {
return this.isLicensed('feat:askAi');
}
isAiCreditsLicensed() {
return this.isLicensed('feat:aiCredits');
}
isAdvancedExecutionFiltersLicensed() {
return this.isLicensed('feat:advancedExecutionFilters');
}
isAdvancedPermissionsLicensed() {
return this.isLicensed('feat:advancedPermissions');
}
isDebugInEditorLicensed() {
return this.isLicensed('feat:debugInEditor');
}
isBinaryDataS3Licensed() {
return this.isLicensed('feat:binaryDataS3');
}
isMultiMainLicensed() {
return this.isLicensed('feat:multipleMainInstances');
}
isVariablesLicensed() {
return this.isLicensed('feat:variables');
}
isSourceControlLicensed() {
return this.isLicensed('feat:sourceControl');
}
isExternalSecretsLicensed() {
return this.isLicensed('feat:externalSecrets');
}
isAPIDisabled() {
return this.isLicensed('feat:apiDisabled');
}
isWorkerViewLicensed() {
return this.isLicensed('feat:workerView');
}
isProjectRoleAdminLicensed() {
return this.isLicensed('feat:projectRole:admin');
}
isProjectRoleEditorLicensed() {
return this.isLicensed('feat:projectRole:editor');
}
isProjectRoleViewerLicensed() {
return this.isLicensed('feat:projectRole:viewer');
}
isCustomNpmRegistryLicensed() {
return this.isLicensed('feat:communityNodes:customRegistry');
}
isFoldersLicensed() {
return this.isLicensed('feat:folders');
}
isInsightsSummaryLicensed() {
return this.isLicensed('feat:insights:viewSummary');
}
isInsightsDashboardLicensed() {
return this.isLicensed('feat:insights:viewDashboard');
}
isInsightsHourlyDataLicensed() {
return this.isLicensed('feat:insights:viewHourlyData');
}
isWorkflowDiffsLicensed() {
return this.isLicensed('feat:workflowDiffs');
}
isProvisioningLicensed() {
return this.isLicensed(['feat:saml', 'feat:oidc']);
}
// --------------------
// integers
// --------------------
getMaxUsers() {
return this.getValue('quota:users') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxActiveWorkflows() {
return this.getValue('quota:activeWorkflows') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxVariables() {
return this.getValue('quota:maxVariables') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxAiCredits() {
return this.getValue('quota:aiCredits') ?? 0;
}
getWorkflowHistoryPruneQuota() {
return this.getValue('quota:workflowHistoryPrune') ?? UNLIMITED_LICENSE_QUOTA;
}
getInsightsMaxHistory() {
return this.getValue('quota:insights:maxHistoryDays') ?? 7;
}
getInsightsRetentionMaxAge() {
return this.getValue('quota:insights:retention:maxAgeDays') ?? 180;
}
getInsightsRetentionPruneInterval() {
return this.getValue('quota:insights:retention:pruneIntervalDays') ?? 24;
}
getMaxTeamProjects() {
return this.getValue('quota:maxTeamProjects') ?? 0;
}
getMaxWorkflowsWithEvaluations() {
return this.getValue('quota:evaluations:maxWorkflows') ?? 0;
}
}
@@ -0,0 +1,604 @@
jest.mock('n8n-workflow', () => ({
...jest.requireActual('n8n-workflow'),
LoggerProxy: { init: jest.fn() },
}));
import type { GlobalConfig, InstanceSettingsConfig } from '@n8n/config';
import { mock, captor } from 'jest-mock-extended';
import { LoggerProxy } from 'n8n-workflow';
import winston from 'winston';
import { Logger } from '../logger';
describe('Logger', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('constructor', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['console'],
scopes: [],
},
});
test('if root, should initialize `LoggerProxy` with instance', () => {
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>(), { isRoot: true });
expect(LoggerProxy.init).toHaveBeenCalledWith(logger);
});
test('if scoped, should not initialize `LoggerProxy`', () => {
new Logger(globalConfig, mock<InstanceSettingsConfig>(), { isRoot: false });
expect(LoggerProxy.init).not.toHaveBeenCalled();
});
});
describe('formats', () => {
afterEach(() => {
jest.resetAllMocks();
});
test('log text, if `config.logging.format` is set to `text`', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'text',
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const testMessage = 'Test Message';
const testMetadata = { test: 1 };
// ACT
logger.info(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
expect(output).toEqual(`${testMessage}\n`);
});
test('log json, if `config.logging.format` is set to `json`', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const testMessage = 'Test Message';
const testMetadata = { test: 1 };
// ACT
logger.info(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
expect(() => JSON.parse(output)).not.toThrow();
const parsedOutput = JSON.parse(output);
expect(parsedOutput).toMatchObject({
message: testMessage,
level: 'info',
metadata: {
...testMetadata,
timestamp: expect.any(String),
},
});
});
test('apply scope filters, if `config.logging.format` is set to `json`', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info',
outputs: ['console'],
scopes: ['push'],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const redisLogger = logger.scoped('redis');
const pushLogger = logger.scoped('push');
const testMessage = 'Test Message';
const testMetadata = { test: 1 };
// ACT
redisLogger.info(testMessage, testMetadata);
pushLogger.info(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(1);
});
test('log errors in metadata with stack trace, if `config.logging.format` is set to `json`', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const testMessage = 'Test Message';
const parentError = new Error('Parent', { cause: 'just a string' });
const testError = new Error('Test', { cause: parentError });
const testMetadata = { error: testError };
// ACT
logger.info(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
expect(() => JSON.parse(output)).not.toThrow();
const parsedOutput = JSON.parse(output);
expect(parsedOutput).toMatchObject({
message: testMessage,
metadata: {
error: {
name: testError.name,
message: testError.message,
stack: testError.stack,
cause: {
name: parentError.name,
message: parentError.message,
stack: parentError.stack,
cause: parentError.cause,
},
},
},
});
});
test('do not recurse indefinitely when `cause` contains circular references', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const testMessage = 'Test Message';
const parentError = new Error('Parent', { cause: 'just a string' });
const childError = new Error('Test', { cause: parentError });
parentError.cause = childError;
const testMetadata = { error: childError };
// ACT
logger.info(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
expect(() => JSON.parse(output)).not.toThrow();
const parsedOutput = JSON.parse(output);
expect(parsedOutput).toMatchObject({
message: testMessage,
metadata: {
error: {
name: childError.name,
message: childError.message,
stack: childError.stack,
cause: {
name: parentError.name,
message: parentError.message,
stack: parentError.stack,
},
},
},
});
});
});
describe('transports', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('if `console` selected, should set console transport', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const { transports } = logger.getInternalLogger();
expect(transports).toHaveLength(1);
const [transport] = transports;
expect(transport.constructor.name).toBe('Console');
});
describe('`file`', () => {
test('should set file transport', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['file'],
scopes: [],
file: {
fileSizeMax: 100,
fileCountMax: 16,
location: 'logs/n8n.log',
},
},
});
const logger = new Logger(
globalConfig,
mock<InstanceSettingsConfig>({ n8nFolder: '/tmp' }),
);
const { transports } = logger.getInternalLogger();
expect(transports).toHaveLength(1);
const [transport] = transports;
expect(transport.constructor.name).toBe('File');
});
test('should accept absolute paths', () => {
// ARRANGE
const location = '/tmp/n8n.log';
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['file'],
scopes: [],
file: { fileSizeMax: 100, fileCountMax: 16, location },
},
});
const OriginalFile = winston.transports.File;
const FileSpy = jest.spyOn(winston.transports, 'File').mockImplementation((...args) => {
return new OriginalFile(...args);
});
// ACT
new Logger(globalConfig, mock<InstanceSettingsConfig>({ n8nFolder: '/tmp' }));
// ASSERT
const fileOptionsCaptor = captor<string>();
expect(FileSpy).toHaveBeenCalledTimes(1);
expect(FileSpy).toHaveBeenCalledWith(fileOptionsCaptor);
expect(fileOptionsCaptor.value).toMatchObject({ filename: location });
});
test('should accept relative paths', () => {
// ARRANGE
const location = 'tmp/n8n.log';
const n8nFolder = '/tmp/n8n';
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['file'],
scopes: [],
file: {
fileSizeMax: 100,
fileCountMax: 16,
location,
},
},
});
const OriginalFile = winston.transports.File;
const FileSpy = jest.spyOn(winston.transports, 'File').mockImplementation((...args) => {
return new OriginalFile(...args);
});
// ACT
new Logger(globalConfig, mock<InstanceSettingsConfig>({ n8nFolder }));
// ASSERT
const fileOptionsCaptor = captor<string>();
expect(FileSpy).toHaveBeenCalledTimes(1);
expect(FileSpy).toHaveBeenCalledWith(fileOptionsCaptor);
expect(fileOptionsCaptor.value).toMatchObject({ filename: `${n8nFolder}/${location}` });
});
});
});
describe('levels', () => {
test('if `error` selected, should enable `error` level', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'error',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const internalLogger = logger.getInternalLogger();
expect(internalLogger.isErrorEnabled()).toBe(true);
expect(internalLogger.isWarnEnabled()).toBe(false);
expect(internalLogger.isInfoEnabled()).toBe(false);
expect(internalLogger.isDebugEnabled()).toBe(false);
});
test('if `warn` selected, should enable `error` and `warn` levels', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'warn',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const internalLogger = logger.getInternalLogger();
expect(internalLogger.isErrorEnabled()).toBe(true);
expect(internalLogger.isWarnEnabled()).toBe(true);
expect(internalLogger.isInfoEnabled()).toBe(false);
expect(internalLogger.isDebugEnabled()).toBe(false);
});
test('if `info` selected, should enable `error`, `warn`, and `info` levels', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'info',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const internalLogger = logger.getInternalLogger();
expect(internalLogger.isErrorEnabled()).toBe(true);
expect(internalLogger.isWarnEnabled()).toBe(true);
expect(internalLogger.isInfoEnabled()).toBe(true);
expect(internalLogger.isDebugEnabled()).toBe(false);
});
test('if `debug` selected, should enable all levels', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'debug',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const internalLogger = logger.getInternalLogger();
expect(internalLogger.isErrorEnabled()).toBe(true);
expect(internalLogger.isWarnEnabled()).toBe(true);
expect(internalLogger.isInfoEnabled()).toBe(true);
expect(internalLogger.isDebugEnabled()).toBe(true);
});
test('if `silent` selected, should disable all levels', () => {
const globalConfig = mock<GlobalConfig>({
logging: {
level: 'silent',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const internalLogger = logger.getInternalLogger();
expect(internalLogger.isErrorEnabled()).toBe(false);
expect(internalLogger.isWarnEnabled()).toBe(false);
expect(internalLogger.isInfoEnabled()).toBe(false);
expect(internalLogger.isDebugEnabled()).toBe(false);
expect(internalLogger.silent).toBe(true);
});
});
describe('production debug logging without color codes', () => {
// eslint-disable-next-line no-control-regex
const ANSI_COLOR_PATTERN = /\x1b\[\d+m/g; // Pattern to match ANSI color escape codes
afterEach(() => {
jest.resetAllMocks();
delete process.env.NO_COLOR;
});
test('production debug logs default to no colors (NO_COLOR not set)', () => {
// ARRANGE
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json', // Use json format so we can check the format property directly
level: 'debug',
outputs: ['console'],
scopes: [],
},
});
// Create logger
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
// ACT
logger.debug('Test message', { testKey: 'testValue' });
// ASSERT
// If json format is used, uncolorize should be in the pipeline (as it's used in debugProdConsoleFormat)
expect(stdoutSpy).toHaveBeenCalled();
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
// JSON logs should be parseable and not contain ANSI codes
expect(() => JSON.parse(output)).not.toThrow();
const hasAnsiCodes = ANSI_COLOR_PATTERN.test(output);
expect(hasAnsiCodes).toBe(false);
});
test('NO_COLOR environment variable is respected and prevents colors', () => {
// ARRANGE
process.env.NO_COLOR = '1';
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info',
outputs: ['console'],
scopes: [],
},
});
// ACT
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
logger.info('Test message with NO_COLOR', { key: 'value' });
// ASSERT
expect(stdoutSpy).toHaveBeenCalled();
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
// Should not contain ANSI color codes even with colorize in dev format
const hasAnsiCodes = ANSI_COLOR_PATTERN.test(output);
expect(hasAnsiCodes).toBe(false);
// Cleanup
delete process.env.NO_COLOR;
});
test('debugProdConsoleFormat produces uncolored structured output', () => {
// ARRANGE
// Note: This test inspects the actual formatter method signature
// We verify that when level is debug in production mode,
// the output doesn't include color codes
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const globalConfig = mock<GlobalConfig>({
logging: {
format: 'json', // Using json to ensure we test the basic behavior
level: 'debug',
outputs: ['console'],
scopes: [],
},
});
const logger = new Logger(globalConfig, mock<InstanceSettingsConfig>());
const testMessage = 'Debug operation completed';
const testMetadata = { operation: 'database_query', duration_ms: 234 };
// ACT
logger.debug(testMessage, testMetadata);
// ASSERT
expect(stdoutSpy).toHaveBeenCalled();
const output = stdoutSpy.mock.lastCall?.[0];
if (typeof output !== 'string') {
fail(`expected 'output' to be of type 'string', got ${typeof output}`);
}
// Verify output is valid JSON (our format configuration)
const parsed = JSON.parse(output) as { message: string; metadata: { operation: string } };
expect(parsed.message).toBe(testMessage);
expect(parsed.metadata.operation).toBe('database_query');
// Most importantly: verify no ANSI color codes are present
const hasAnsiCodes = ANSI_COLOR_PATTERN.test(output);
expect(hasAnsiCodes).toBe(false);
});
test('logger format selection respects environment and level', () => {
// ARRANGE
// Create two loggers with different configurations
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
const infoProdConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'info', // Not debug level
outputs: ['console'],
scopes: [],
},
});
const debugProdConfig = mock<GlobalConfig>({
logging: {
format: 'json',
level: 'debug', // Debug level - should use debugProdConsoleFormat when in production
outputs: ['console'],
scopes: [],
},
});
// ACT
const infoLogger = new Logger(infoProdConfig, mock<InstanceSettingsConfig>());
const debugLogger = new Logger(debugProdConfig, mock<InstanceSettingsConfig>());
infoLogger.info('Info level message', {});
debugLogger.debug('Debug level message', { context: 'important' });
// ASSERT
expect(stdoutSpy).toHaveBeenCalledTimes(2);
// Both outputs should be ANSI-free
const infoOutput = stdoutSpy.mock.calls[0]?.[0];
const debugOutput = stdoutSpy.mock.calls[1]?.[0];
if (typeof infoOutput !== 'string' || typeof debugOutput !== 'string') {
fail('expected both outputs to be strings');
}
expect(ANSI_COLOR_PATTERN.test(infoOutput)).toBe(false);
expect(ANSI_COLOR_PATTERN.test(debugOutput)).toBe(false);
});
});
});
@@ -0,0 +1 @@
export { Logger } from './logger';
@@ -0,0 +1,289 @@
import type { LogScope } from '@n8n/config';
import { GlobalConfig, InstanceSettingsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import callsites from 'callsites';
import type { TransformableInfo } from 'logform';
import { LoggerProxy, LOG_LEVELS } from 'n8n-workflow';
import type {
Logger as LoggerType,
LogLocationMetadata,
LogLevel,
LogMetadata,
} from 'n8n-workflow';
import path, { basename } from 'node:path';
import pc from 'picocolors';
import winston from 'winston';
import { inDevelopment, inProduction } from '../environment';
import { isObjectLiteral } from '../utils/is-object-literal';
const noOp = () => {};
@Service()
export class Logger implements LoggerType {
private internalLogger: winston.Logger;
private readonly level: LogLevel;
private readonly scopes: Set<LogScope>;
private get isScopingEnabled() {
return this.scopes.size > 0;
}
/** https://no-color.org/ */
private readonly noColor = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
// Allow opt-in coloring in production by setting NO_COLOR to 'false' or '0'
private readonly noColorDefaultTrue =
process.env.NO_COLOR !== 'false' && process.env.NO_COLOR !== '0';
constructor(
private readonly globalConfig: GlobalConfig,
private readonly instanceSettingsConfig: InstanceSettingsConfig,
{ isRoot }: { isRoot?: boolean } = { isRoot: true },
) {
this.level = this.globalConfig.logging.level;
const isSilent = this.level === 'silent';
this.internalLogger = winston.createLogger({
level: this.level,
silent: isSilent,
});
if (!isSilent) {
this.setLevel();
const { outputs, scopes } = this.globalConfig.logging;
if (outputs.includes('console')) this.setConsoleTransport();
if (outputs.includes('file')) this.setFileTransport();
this.scopes = new Set(scopes);
} else {
this.scopes = new Set();
}
if (isRoot) LoggerProxy.init(this);
}
private setInternalLogger(internalLogger: winston.Logger) {
this.internalLogger = internalLogger;
}
/** Create a logger that injects the given scopes into its log metadata. */
scoped(scopes: LogScope | LogScope[]) {
scopes = Array.isArray(scopes) ? scopes : [scopes];
const scopedLogger = new Logger(this.globalConfig, this.instanceSettingsConfig, {
isRoot: false,
});
const childLogger = this.internalLogger.child({ scopes });
scopedLogger.setInternalLogger(childLogger);
return scopedLogger;
}
private serializeError(
error: unknown,
seen: Set<unknown> = new Set(),
): { name: string; message: string; stack?: string; cause: unknown } | string {
if (!(error instanceof Error)) return String(error);
// prevent infinite recursion
let cause: unknown;
if (error.cause && !seen.has(error.cause)) {
seen.add(error.cause);
cause = this.serializeError(error.cause, seen);
}
return {
name: error.name,
message: error.message,
stack: error.stack,
cause,
};
}
private log(level: LogLevel, message: string, metadata: LogMetadata) {
const location: LogLocationMetadata = {};
const caller = callsites().at(2); // zeroth and first are this file, second is caller
if (caller !== undefined) {
location.file = basename(caller.getFileName() ?? '');
const fnName = caller.getFunctionName();
if (fnName) location.function = fnName;
}
for (const key of Object.keys(metadata)) {
const value = metadata[key];
if (value instanceof Error) {
metadata[key] = this.serializeError(value);
}
}
this.internalLogger.log(level, message, { ...metadata, ...location });
}
private setLevel() {
const { levels } = this.internalLogger;
for (const logLevel of LOG_LEVELS) {
if (levels[logLevel] > levels[this.level]) {
// numerically higher (less severe) log levels become no-op
// to prevent overhead from `callsites` calls
Object.defineProperty(this, logLevel, { value: noOp });
}
}
}
private jsonConsoleFormat() {
return winston.format.combine(
winston.format.timestamp(),
winston.format.metadata(),
winston.format.json(),
this.scopeFilter(),
);
}
private pickConsoleTransportFormat() {
if (this.globalConfig.logging.format === 'json') {
return this.jsonConsoleFormat();
} else if (this.level === 'debug' && inDevelopment) {
return this.debugDevConsoleFormat();
} else if (this.level === 'debug' && inProduction) {
return this.debugProdConsoleFormat();
} else {
return winston.format.printf(({ message }: { message: string }) => message);
}
}
private setConsoleTransport() {
const format = this.pickConsoleTransportFormat();
this.internalLogger.add(new winston.transports.Console({ format }));
}
private scopeFilter() {
return winston.format((info: TransformableInfo) => {
if (!this.isScopingEnabled) return info;
const { scopes } = (info as unknown as { metadata: LogMetadata }).metadata;
const shouldIncludeScope =
scopes && scopes?.length > 0 && scopes.some((s) => this.scopes.has(s));
return shouldIncludeScope ? info : false;
})();
}
private color(defaultToTrue: boolean = false) {
if (defaultToTrue) {
return this.noColorDefaultTrue
? winston.format.uncolorize()
: winston.format.colorize({ all: true });
}
// For development: respect NO_COLOR, otherwise colorize
return this.noColor ? winston.format.uncolorize() : winston.format.colorize({ all: true });
}
private debugDevConsoleFormat() {
return winston.format.combine(
winston.format.metadata(),
winston.format.timestamp({ format: () => this.devTsFormat() }),
this.color(),
this.scopeFilter(),
winston.format.printf(({ level: rawLevel, message, timestamp, metadata: rawMetadata }) => {
const separator = ' '.repeat(3);
const logLevelColumnWidth = this.noColor ? 5 : 15; // when colorizing, account for ANSI color codes
const level = rawLevel.toLowerCase().padEnd(logLevelColumnWidth, ' ');
const metadata = this.toPrintable(rawMetadata);
return [timestamp, level, message + ' ' + pc.dim(metadata)].join(separator);
}),
);
}
private debugProdConsoleFormat() {
return winston.format.combine(
winston.format.metadata(),
winston.format.timestamp(),
this.color(true), // Default to no colors in production
this.scopeFilter(),
winston.format.printf(({ level, message, timestamp, metadata: rawMetadata }) => {
const metadata = this.toPrintable(rawMetadata);
return `${timestamp} | ${level.padEnd(5)} | ${message}${metadata ? ' ' + metadata : ''}`;
}),
);
}
private devTsFormat() {
const now = new Date();
const pad = (num: number, digits: number = 2) => num.toString().padStart(digits, '0');
const hours = pad(now.getHours());
const minutes = pad(now.getMinutes());
const seconds = pad(now.getSeconds());
const milliseconds = pad(now.getMilliseconds(), 3);
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
private toPrintable(metadata: unknown) {
if (isObjectLiteral(metadata) && Object.keys(metadata).length > 0) {
return inProduction
? JSON.stringify(metadata)
: JSON.stringify(metadata)
.replace(/{"/g, '{ "')
.replace(/,"/g, ', "')
.replace(/:/g, ': ')
.replace(/}/g, ' }'); // spacing for readability
}
return '';
}
private setFileTransport() {
const filename = path.isAbsolute(this.globalConfig.logging.file.location)
? this.globalConfig.logging.file.location
: path.join(this.instanceSettingsConfig.n8nFolder, this.globalConfig.logging.file.location);
const { fileSizeMax, fileCountMax } = this.globalConfig.logging.file;
this.internalLogger.add(
new winston.transports.File({
filename,
format: this.jsonConsoleFormat(),
maxsize: fileSizeMax * 1_048_576, // config * 1 MiB in bytes
maxFiles: fileCountMax,
}),
);
}
// #region Convenience methods
error(message: string, metadata: LogMetadata = {}) {
this.log('error', message, metadata);
}
warn(message: string, metadata: LogMetadata = {}) {
this.log('warn', message, metadata);
}
info(message: string, metadata: LogMetadata = {}) {
this.log('info', message, metadata);
}
debug(message: string, metadata: LogMetadata = {}) {
this.log('debug', message, metadata);
}
// #endregion
// #region For testing only
getInternalLogger() {
return this.internalLogger;
}
// #endregion
}
@@ -0,0 +1,346 @@
import type { ModuleInterface, ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { LicenseState } from '../../license-state';
import { ModuleConfusionError } from '../errors/module-confusion.error';
import { ModuleRegistry } from '../module-registry';
import { MODULE_NAMES } from '../modules.config';
beforeEach(() => {
jest.resetAllMocks();
process.env = {};
Container.reset();
});
describe('eligibleModules', () => {
it('should consider all default modules eligible', () => {
expect(Container.get(ModuleRegistry).eligibleModules).toEqual(MODULE_NAMES);
});
it('should consider a module ineligible if it was disabled via env var', () => {
process.env.N8N_DISABLED_MODULES = 'insights';
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
'external-secrets',
'community-packages',
'data-table',
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
'sso-oidc',
'sso-saml',
'log-streaming',
'ldap',
'quick-connect',
'workflow-builder',
'redaction',
'instance-registry',
]);
});
it('should consider a module eligible if it was enabled via env var', () => {
process.env.N8N_ENABLED_MODULES = 'data-table';
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
'insights',
'external-secrets',
'community-packages',
'data-table',
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
'sso-oidc',
'sso-saml',
'log-streaming',
'ldap',
'quick-connect',
'workflow-builder',
'redaction',
'instance-registry',
]);
});
it('should throw `ModuleConfusionError` if a module is both enabled and disabled', () => {
process.env.N8N_ENABLED_MODULES = 'insights';
process.env.N8N_DISABLED_MODULES = 'insights';
expect(() => Container.get(ModuleRegistry).eligibleModules).toThrow(ModuleConfusionError);
});
});
describe('loadModules', () => {
it('should load entities defined by modules', async () => {
const FirstEntity = class FirstEntityClass {};
const SecondEntity = class SecondEntityClass {};
const ModuleClass = {
entities: jest.fn().mockReturnValue([FirstEntity, SecondEntity]),
};
const moduleMetadata = mock<ModuleMetadata>({
getClasses: jest.fn().mockReturnValue([ModuleClass]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.loadModules([]);
expect(moduleRegistry.entities).toEqual([FirstEntity, SecondEntity]);
});
it('should load no entities if none are defined by modules', async () => {
const ModuleClass = { entities: jest.fn().mockReturnValue([]) };
const moduleMetadata = mock<ModuleMetadata>({
getClasses: jest.fn().mockReturnValue([ModuleClass]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.loadModules([]);
expect(moduleRegistry.entities).toEqual([]);
});
});
describe('initModules', () => {
it('should init module if it has no feature flag', async () => {
const ModuleClass = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules('main');
expect(ModuleClass.init).toHaveBeenCalled();
});
it('should init module if it is licensed', async () => {
const ModuleClass = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
]),
});
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(true) });
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
await moduleRegistry.initModules('main');
expect(ModuleClass.init).toHaveBeenCalled();
});
it('should skip init for unlicensed module', async () => {
const ModuleClass = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
]),
});
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(false) });
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
await moduleRegistry.initModules('main');
expect(ModuleClass.init).not.toHaveBeenCalled();
});
it('should accept module without `init` method', async () => {
const ModuleClass = {};
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules('main');
await expect(moduleRegistry.initModules('main')).resolves.not.toThrow();
});
it('registers settings', async () => {
// ARRANGE
const moduleName = 'test-module';
const moduleSettings = { foo: 1 };
const ModuleClass: ModuleInterface = {
init: jest.fn(),
settings: jest.fn().mockReturnValue(moduleSettings),
};
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules('main');
// ASSERT
expect(ModuleClass.settings).toHaveBeenCalled();
expect(moduleRegistry.settings.has(moduleName)).toBe(true);
expect(moduleRegistry.settings.get(moduleName)).toBe(moduleSettings);
});
it('activates module with settings', async () => {
// ARRANGE
const moduleName = 'test-module';
const moduleSettings = { foo: 1 };
const ModuleClass: ModuleInterface = {
init: jest.fn(),
settings: jest.fn().mockReturnValue(moduleSettings),
};
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules('main');
// ASSERT
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
});
it('activates module without settings', async () => {
// ARRANGE
const moduleName = 'test-module';
const ModuleClass: ModuleInterface = {
init: jest.fn(),
};
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules('main');
// ASSERT
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
});
it('registers context for module with `context` method', async () => {
// ARRANGE
const moduleName = 'test-module';
const moduleContext = { proxy: 'test-proxy', config: { enabled: true } };
const ModuleClass: ModuleInterface = {
init: jest.fn(),
context: jest.fn().mockReturnValue(moduleContext),
};
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules('main');
// ASSERT
expect(ModuleClass.context).toHaveBeenCalled();
expect(moduleRegistry.context.has(moduleName)).toBe(true);
expect(moduleRegistry.context.get(moduleName)).toBe(moduleContext);
});
it('does not register context for module without `context` method', async () => {
// ARRANGE
const moduleName = 'test-module';
const ModuleClass: ModuleInterface = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules('main');
// ASSERT
expect(moduleRegistry.context.has(moduleName)).toBe(false);
});
it('should init module with matching instance type', async () => {
const ModuleClass = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([
['test-module', { instanceTypes: ['main', 'worker'], class: ModuleClass }],
]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules('main');
expect(ModuleClass.init).toHaveBeenCalled();
});
it('should skip init for module with non-matching instance type', async () => {
const ModuleClass = { init: jest.fn() };
const moduleMetadata = mock<ModuleMetadata>({
getEntries: jest
.fn()
.mockReturnValue([['test-module', { instanceTypes: ['worker'], class: ModuleClass }]]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules('main');
expect(ModuleClass.init).not.toHaveBeenCalled();
});
});
describe('loadDir', () => {
it('should load dirs defined by modules', async () => {
const TEST_LOAD_DIR = '/path/to/module/load/dir';
const ModuleClass = {
entities: jest.fn().mockReturnValue([]),
loadDir: jest.fn().mockReturnValue(TEST_LOAD_DIR),
};
const moduleMetadata = mock<ModuleMetadata>({
getClasses: jest.fn().mockReturnValue([ModuleClass]),
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.loadModules([]); // empty to skip dynamic imports
expect(moduleRegistry.loadDirs).toEqual([TEST_LOAD_DIR]);
});
});
@@ -0,0 +1,20 @@
import { Container } from '@n8n/di';
import { UnknownModuleError } from '../errors/unknown-module.error';
import { ModulesConfig } from '../modules.config';
beforeEach(() => {
jest.resetAllMocks();
process.env = {};
Container.reset();
});
it('should throw `UnknownModuleError` if any enabled module name is invalid', () => {
process.env.N8N_ENABLED_MODULES = 'insights,invalidModule';
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
});
it('should throw `UnknownModuleError` if any disabled module name is invalid', () => {
process.env.N8N_DISABLED_MODULES = 'insights,invalidModule';
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
});
@@ -0,0 +1,9 @@
import { UserError } from 'n8n-workflow';
export class MissingModuleError extends UserError {
constructor(moduleName: string, errorMsg: string) {
super(
`Failed to load module "${moduleName}": ${errorMsg}. Please review the module's entrypoint file name and the module's directory name.`,
);
}
}
@@ -0,0 +1,11 @@
import { UserError } from 'n8n-workflow';
export class ModuleConfusionError extends UserError {
constructor(moduleNames: string[]) {
const modules = moduleNames.length > 1 ? 'modules' : 'a module';
super(
`Found ${modules} listed in both \`N8N_ENABLED_MODULES\` and \`N8N_DISABLED_MODULES\`: ${moduleNames.join(', ')}. Please review your environment variables, as a module cannot be both enabled and disabled.`,
);
}
}
@@ -0,0 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
export class UnknownModuleError extends UnexpectedError {
constructor(moduleName: string) {
super(`Unknown module "${moduleName}"`, { level: 'fatal' });
}
}
@@ -0,0 +1,204 @@
import type { InstanceType } from '@n8n/constants';
import { ModuleMetadata } from '@n8n/decorators';
import type { EntityClass, ModuleContext, ModuleSettings } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { existsSync } from 'fs';
import path from 'path';
import { MissingModuleError } from './errors/missing-module.error';
import { ModuleConfusionError } from './errors/module-confusion.error';
import { ModulesConfig } from './modules.config';
import type { ModuleName } from './modules.config';
import { LicenseState } from '../license-state';
import { Logger } from '../logging/logger';
@Service()
export class ModuleRegistry {
readonly entities: EntityClass[] = [];
readonly loadDirs: string[] = [];
readonly settings: Map<string, ModuleSettings> = new Map();
readonly context: Map<string, ModuleContext> = new Map();
constructor(
private readonly moduleMetadata: ModuleMetadata,
private readonly licenseState: LicenseState,
private readonly logger: Logger,
private readonly modulesConfig: ModulesConfig,
) {}
private readonly defaultModules: ModuleName[] = [
'insights',
'external-secrets',
'community-packages',
'data-table',
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
'sso-oidc',
'sso-saml',
'log-streaming',
'ldap',
'quick-connect',
'workflow-builder',
'redaction',
'instance-registry',
];
private readonly activeModules: string[] = [];
get eligibleModules(): ModuleName[] {
const { enabledModules, disabledModules } = this.modulesConfig;
const doubleListed = enabledModules.filter((m) => disabledModules.includes(m));
if (doubleListed.length > 0) throw new ModuleConfusionError(doubleListed);
const defaultPlusEnabled = [...new Set([...this.defaultModules, ...enabledModules])];
return defaultPlusEnabled.filter((m) => !disabledModules.includes(m));
}
/**
* Loads [module name].module.ts for each eligible module.
* This only registers the database entities for module and should be done
* before instantiating the datasource.
*
* This will not register routes or do any other kind of module related
* setup.
*/
async loadModules(modules?: ModuleName[]) {
let modulesDir: string;
try {
// docker + tests
const n8nPackagePath = require.resolve('n8n/package.json');
const n8nRoot = path.dirname(n8nPackagePath);
const srcDirExists = existsSync(path.join(n8nRoot, 'src'));
const dir = process.env.NODE_ENV === 'test' && srcDirExists ? 'src' : 'dist';
modulesDir = path.join(n8nRoot, dir, 'modules');
} catch {
// local dev
// n8n binary is inside the bin folder, so we need to go up two levels
modulesDir = path.resolve(process.argv[1], '../../dist/modules');
}
for (const moduleName of modules ?? this.eligibleModules) {
try {
await import(`${modulesDir}/${moduleName}/${moduleName}.module`);
} catch {
try {
await import(`${modulesDir}/${moduleName}.ee/${moduleName}.module`);
} catch (error) {
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
}
}
}
for (const ModuleClass of this.moduleMetadata.getClasses()) {
const entities = await Container.get(ModuleClass).entities?.();
if (entities?.length) this.entities.push(...entities);
const loadDir = await Container.get(ModuleClass).loadDir?.();
if (loadDir) this.loadDirs.push(loadDir);
await Container.get(ModuleClass).commands?.();
}
}
/**
* Calls `init` on each eligible module.
*
* This will do things like registering routes, setup timers or other module
* specific setup.
*
* `ModuleRegistry.loadModules` must have been called before.
*/
async initModules(instanceType: InstanceType) {
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
const { licenseFlag, instanceTypes, class: ModuleClass } = moduleEntry;
if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) {
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
continue;
}
if (instanceTypes !== undefined && !instanceTypes.includes(instanceType)) {
this.logger.debug(
`Skipped init for module "${moduleName}" (instance type "${instanceType}" not in: ${instanceTypes.join(', ')})`,
);
continue;
}
await Container.get(ModuleClass).init?.();
const moduleSettings = await Container.get(ModuleClass).settings?.();
if (moduleSettings) this.settings.set(moduleName, moduleSettings);
const moduleContext = await Container.get(ModuleClass).context?.();
if (moduleContext) this.context.set(moduleName, moduleContext);
this.logger.debug(`Initialized module "${moduleName}"`);
this.activeModules.push(moduleName);
}
}
/**
* Refreshes the settings for a specific module by calling its `settings` method.
* This will make sure that any changes to the module's settings are reflected in the registry
* and in turn available to other parts of the application (like front-end settings service).
* If the module does not provide settings, it removes any existing settings for that module.
*/
async refreshModuleSettings(moduleName: ModuleName) {
const moduleEntry = this.moduleMetadata.get(moduleName);
if (!moduleEntry) {
this.logger.debug('Skipping settings refresh for unregistered module', { moduleName });
return null;
}
const moduleSettings = await Container.get(moduleEntry.class).settings?.();
if (moduleSettings) {
this.settings.set(moduleName, moduleSettings);
} else {
this.settings.delete(moduleName);
}
return moduleSettings ?? null;
}
async shutdownModule(moduleName: ModuleName) {
const moduleEntry = this.moduleMetadata.get(moduleName);
if (!moduleEntry) {
this.logger.debug('Skipping shutdown for unregistered module', { moduleName });
return;
}
await Container.get(moduleEntry.class).shutdown?.();
const index = this.activeModules.indexOf(moduleName);
if (index > -1) this.activeModules.splice(index, 1);
this.logger.debug(`Shut down module "${moduleName}"`);
}
isActive(moduleName: ModuleName) {
return this.activeModules.includes(moduleName);
}
getActiveModules() {
return this.activeModules;
}
}
@@ -0,0 +1,47 @@
import { CommaSeparatedStringArray, Config, Env } from '@n8n/config';
import { UnknownModuleError } from './errors/unknown-module.error';
export const MODULE_NAMES = [
'insights',
'external-secrets',
'community-packages',
'data-table',
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
'sso-oidc',
'sso-saml',
'log-streaming',
'ldap',
'quick-connect',
'workflow-builder',
'redaction',
'instance-registry',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];
class ModuleArray extends CommaSeparatedStringArray<ModuleName> {
constructor(str: string) {
super(str);
for (const moduleName of this) {
if (!MODULE_NAMES.includes(moduleName)) throw new UnknownModuleError(moduleName);
}
}
}
@Config
export class ModulesConfig {
/** Comma-separated list of all enabled modules. */
@Env('N8N_ENABLED_MODULES')
enabledModules: ModuleArray = [];
/** Comma-separated list of all disabled modules. */
@Env('N8N_DISABLED_MODULES')
disabledModules: ModuleArray = [];
}
+15
View File
@@ -0,0 +1,15 @@
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
export type FeatureReturnType = Partial<
{
planName: string;
} & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean }
>;
export interface LicenseProvider {
/** Returns whether a feature is included in the user's license plan. */
isLicensed(feature: BooleanLicenseFeature): boolean;
/** Returns the value of a feature in the user's license plan, typically a boolean or integer. */
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T];
}
@@ -0,0 +1,126 @@
import { parse, stringify } from 'flatted';
import { parseFlattedAsync } from '../flatted-async';
describe('parseFlattedAsync', () => {
it('should parse simple objects', async () => {
const original = { name: 'test', count: 42, active: true };
const flattedString = stringify(original);
const result = await parseFlattedAsync(flattedString);
expect(result).toEqual(original);
});
it('should parse nested objects', async () => {
const original = {
node1: { data: [1, 2, 3], meta: { type: 'trigger' } },
node2: { data: ['a', 'b'], meta: { type: 'action' } },
};
const flattedString = stringify(original);
const result = await parseFlattedAsync(flattedString);
expect(result).toEqual(original);
});
it('should handle circular references', async () => {
const original: Record<string, unknown> = { name: 'root' };
original.self = original;
const flattedString = stringify(original);
const result = await parseFlattedAsync(flattedString);
expect((result as Record<string, unknown>).name).toBe('root');
expect((result as Record<string, unknown>).self).toBe(result);
});
it('should handle arrays with circular references', async () => {
const arr: unknown[] = [1, 2, 3];
arr.push(arr);
const flattedString = stringify(arr);
const result = await parseFlattedAsync(flattedString);
expect(Array.isArray(result)).toBe(true);
const resultArr = result as unknown[];
expect(resultArr[0]).toBe(1);
expect(resultArr[1]).toBe(2);
expect(resultArr[2]).toBe(3);
expect(resultArr[3]).toBe(resultArr);
});
it('should handle null and primitive values', async () => {
expect(await parseFlattedAsync(stringify(null))).toBeNull();
expect(await parseFlattedAsync(stringify(42))).toBe(42);
expect(await parseFlattedAsync(stringify('hello'))).toBe('hello');
expect(await parseFlattedAsync(stringify(true))).toBe(true);
});
it('should handle empty objects and arrays', async () => {
expect(await parseFlattedAsync(stringify({}))).toEqual({});
expect(await parseFlattedAsync(stringify([]))).toEqual([]);
});
it('should produce the same result as flatted.parse for complex data', async () => {
const original = {
resultData: {
runData: {
Node1: [
{
startTime: 1234567890,
executionTime: 100,
data: { main: [[{ json: { id: 1, name: 'item1' } }]] },
},
],
Node2: [
{
startTime: 1234567990,
executionTime: 200,
data: { main: [[{ json: { id: 2, name: 'item2', nested: { deep: true } } }]] },
},
],
},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const flattedString = stringify(original);
const syncResult = parse(flattedString);
const asyncResult = await parseFlattedAsync(flattedString);
expect(asyncResult).toEqual(syncResult);
});
it('should handle shared references between objects', async () => {
const shared = { key: 'shared-value' };
const original = { a: shared, b: shared };
const flattedString = stringify(original);
const result = (await parseFlattedAsync(flattedString)) as Record<string, unknown>;
expect(result.a).toEqual(shared);
expect(result.b).toEqual(shared);
// flatted preserves reference identity
expect(result.a).toBe(result.b);
});
it('should correctly parse large payloads', async () => {
// Generate data large enough to exercise chunked streaming
const items: Array<{ id: number; data: string }> = [];
for (let i = 0; i < 5000; i++) {
items.push({ id: i, data: `item-${i}-${'x'.repeat(1200)}` });
}
const original = { resultData: { runData: { Node1: [{ data: items }] } } };
const flattedString = stringify(original);
expect(flattedString.length).toBeGreaterThan(5 * 1024 * 1024);
const syncResult = parse(flattedString);
const asyncResult = await parseFlattedAsync(flattedString);
expect(asyncResult).toEqual(syncResult);
});
});
@@ -0,0 +1,35 @@
import { isObjectLiteral } from '../is-object-literal';
describe('isObjectLiteral', () => {
test.each([
['empty object literal', {}, true],
['object with properties', { foo: 'bar', num: 123 }, true],
['nested object literal', { nested: { foo: 'bar' } }, true],
['object with symbol key', { [Symbol.for('foo')]: 'bar' }, true],
['null', null, false],
['empty array', [], false],
['array with values', [1, 2, 3], false],
['number', 42, false],
['string', 'string', false],
['boolean', true, false],
['undefined', undefined, false],
['Date object', new Date('2020-01-01'), false],
['RegExp object', new RegExp(''), false],
['Map object', new Map(), false],
['Set object', new Set(), false],
['arrow function', () => {}, false],
['regular function', function () {}, false],
['class instance', new (class TestClass {})(), false],
['object with custom prototype', Object.create({ customMethod: () => {} }), true],
['Object.create(null)', Object.create(null), false],
['Buffer', Buffer.from('test'), false],
['Serialized Buffer', Buffer.from('test').toJSON(), true],
['Promise', new Promise(() => {}), false],
])('isObjectLiteral(%s)', (_, input, expected) => {
expect(isObjectLiteral(input)).toBe(expected);
});
it('should return false for Error objects', () => {
expect(isObjectLiteral(new Error())).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { stringify } from 'flatted';
import * as flattedAsync from '../flatted-async';
import { parseFlatted, SIZE_THRESHOLD } from '../parse-flatted';
jest.mock('../flatted-async');
describe('parseFlatted', () => {
const parseFlattedAsyncMock = jest.mocked(flattedAsync.parseFlattedAsync);
beforeEach(() => {
jest.clearAllMocks();
});
it('should use sync flatted.parse for small data', async () => {
const original = { name: 'test' };
const flattedString = stringify(original);
const result = await parseFlatted(flattedString);
expect(result).toEqual(original);
expect(parseFlattedAsyncMock).not.toHaveBeenCalled();
});
it('should use sync flatted.parse for data below the threshold', async () => {
// Valid flatted JSON padded to just under the threshold
const padding = stringify({ data: 'x'.repeat(100) });
expect(padding.length).toBeLessThan(SIZE_THRESHOLD);
await parseFlatted(padding);
expect(parseFlattedAsyncMock).not.toHaveBeenCalled();
});
it('should use parseFlattedAsync for data at or above the threshold', async () => {
const aboveThreshold = '[' + '"x"'.repeat(SIZE_THRESHOLD) + ']';
parseFlattedAsyncMock.mockResolvedValue({ parsed: true });
const result = await parseFlatted(aboveThreshold);
expect(parseFlattedAsyncMock).toHaveBeenCalledWith(aboveThreshold);
expect(result).toEqual({ parsed: true });
});
});
@@ -0,0 +1,53 @@
import { isContainedWithin, safeJoinPath } from '../path-util';
describe('isContainedWithin', () => {
it('should return true when parent and child paths are the same', () => {
expect(isContainedWithin('/some/parent/folder', '/some/parent/folder')).toBe(true);
});
test.each([
['/some/parent/folder', '/some/parent/folder/subfolder/file.txt'],
['/some/parent/folder', '/some/parent/folder/../folder/subfolder/file.txt'],
['/some/parent/folder/', '/some/parent/folder/subfolder/file.txt'],
['/some/parent/folder', '/some/parent/folder/subfolder/'],
])('should return true for parent %s and child %s', (parent, child) => {
expect(isContainedWithin(parent, child)).toBe(true);
});
test.each([
['/some/parent/folder', '/some/other/folder/file.txt'],
['/some/parent/folder', '/some/parent/folder_but_not_really'],
['/one/path', '/another/path'],
])('should return false for parent %s and child %s', (parent, child) => {
expect(isContainedWithin(parent, child)).toBe(false);
});
});
describe('safeJoinPath', () => {
it('should join valid paths successfully', () => {
expect(safeJoinPath('path', '')).toBe('path');
expect(safeJoinPath('path', '.')).toBe('path');
expect(safeJoinPath('path', '../path')).toBe('path');
expect(safeJoinPath('path', 'foo')).toBe('path/foo');
expect(safeJoinPath('path', 'foo/file.json')).toBe('path/foo/file.json');
expect(safeJoinPath('path', './foo/file.json')).toBe('path/foo/file.json');
expect(safeJoinPath('path', './foo/../file.json')).toBe('path/file.json');
expect(safeJoinPath('/foo/bar', 'baz')).toBe('/foo/bar/baz');
expect(safeJoinPath('/foo/bar/', 'baz')).toBe('/foo/bar/baz');
expect(safeJoinPath('/foo', '')).toBe('/foo');
expect(safeJoinPath('/foo', '.')).toBe('/foo');
expect(safeJoinPath('/foo', 'bar//baz')).toBe('/foo/bar/baz');
expect(safeJoinPath('/foo', 'bar/../baz')).toBe('/foo/baz');
expect(safeJoinPath('/foo', '/bar/baz')).toBe('/foo/bar/baz');
expect(safeJoinPath('/foo', '.././foo/bar')).toBe('/foo/bar');
});
it('should throw an error for invalid paths', () => {
expect(() => safeJoinPath('path', '../outside/file.json')).toThrow('Path traversal detected');
expect(() => safeJoinPath('path', './foo/../../file.json')).toThrow('Path traversal detected');
expect(() => safeJoinPath('/foo/bar', '../../baz')).toThrow('Path traversal detected');
expect(() => safeJoinPath('/foo/bar', '../baz')).toThrow('Path traversal detected');
expect(() => safeJoinPath('path', '..')).toThrow('Path traversal detected');
expect(() => safeJoinPath('/foo/bar', '..')).toThrow('Path traversal detected');
});
});
@@ -0,0 +1,154 @@
/**
* Async flatted JSON parsing using stream-json.
*
* For large execution data, synchronous flatted.parse() blocks the event loop.
* This module provides an async alternative that streams the JSON in 64KB
* chunks, yielding to the event loop between chunks via setImmediate.
*/
import { ensureError } from 'n8n-workflow';
import { Readable } from 'stream';
import { parser } from 'stream-json';
import Asm from 'stream-json/Assembler';
// 64 KB chunks — selected via benchmarks as the sweet spot for event-loop
// responsiveness vs scheduling overhead (p95 lag ~4 ms at 50 MB payload).
const CHUNK_SIZE = 64 * 1024;
//#region Flatted reference resolution (extracted from flatted@3.2.7)
const Primitive = String;
const primitive = 'string';
const object = 'object';
const ignore = {};
const noop = (_: string, value: unknown) => value;
const primitives = (value: unknown) =>
value instanceof Primitive ? Primitive(value as string) : value;
const revive = (
input: unknown[],
parsed: Set<unknown>,
output: Record<string, unknown>,
$: (key: string, value: unknown) => unknown,
): Record<string, unknown> => {
const lazy: Array<{
k: string;
a: [unknown[], Set<unknown>, Record<string, unknown>, typeof $];
}> = [];
const ke = Object.keys(output);
for (let y = 0; y < ke.length; y++) {
const k = ke[y];
const value = output[k];
if (value instanceof Primitive) {
const tmp = (input as unknown as Record<string, unknown>)[value as unknown as string];
if (typeof tmp === object && !parsed.has(tmp)) {
parsed.add(tmp);
output[k] = ignore;
lazy.push({ k, a: [input, parsed, tmp as Record<string, unknown>, $] });
} else {
output[k] = $.call(output, k, tmp);
}
} else if (output[k] !== ignore) {
output[k] = $.call(output, k, value);
}
}
for (let i = 0; i < lazy.length; i++) {
const { k, a } = lazy[i];
output[k] = $.call(output, k, revive(...a));
}
return output;
};
/**
* Resolve flatted references on a pre-parsed array.
* This reconstructs the original object graph including circular references.
*/
function resolveFlatted(rawArray: unknown[]): unknown {
const input = rawArray;
const value = input[0];
const $ = noop;
const tmp =
typeof value === object && value
? revive(input, new Set(), value as Record<string, unknown>, $)
: value;
return $.call({ '': tmp }, '', tmp);
}
//#endregion Flatted reference resolution
/**
* Recursively convert every string value into a String object wrapper.
* This replicates what JSON.parse(text, Primitives) does so that flatted
* can distinguish index references from literal string values.
*/
function applyPrimitivesDeep(value: unknown): unknown {
if (typeof value === primitive) {
return new Primitive(value);
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
value[i] = applyPrimitivesDeep(value[i]);
}
return value;
}
if (typeof value === object && value !== null) {
const ke = Object.keys(value as Record<string, unknown>);
for (let i = 0; i < ke.length; i++) {
const k = ke[i];
(value as Record<string, unknown>)[k] = applyPrimitivesDeep(
(value as Record<string, unknown>)[k],
);
}
return value;
}
return value;
}
/**
* Prepare raw parsed JSON for flatted reference resolution.
* Applies the Primitives transformation then unwraps String objects.
*/
function prepareFlatted(rawParsed: unknown[]): unknown[] {
return (applyPrimitivesDeep(rawParsed) as unknown[]).map(primitives);
}
/**
* Parse a flatted JSON string asynchronously using stream-json.
* Streams the string in 64KB chunks, yielding to the event loop between chunks
* via setImmediate, then resolves flatted references synchronously.
*/
export async function parseFlattedAsync(flattedString: string): Promise<unknown> {
return await new Promise((resolve, reject) => {
let offset = 0;
const readable = new Readable({
read() {
if (offset >= flattedString.length) {
this.push(null);
return;
}
const chunk = flattedString.slice(offset, offset + CHUNK_SIZE);
offset += CHUNK_SIZE;
setImmediate(() => {
this.push(chunk);
});
},
});
const jsonParser = parser();
const asm = Asm.connectTo(jsonParser);
asm.on('done', (asmResult: { current: unknown[] }) => {
try {
const prepared = prepareFlatted(asmResult.current);
resolve(resolveFlatted(prepared));
} catch (e) {
reject(ensureError(e));
}
});
jsonParser.on('error', reject);
readable.on('error', reject);
readable.pipe(jsonParser);
});
}
@@ -0,0 +1,20 @@
import fs from 'node:fs/promises';
export async function assertDir(dir: string) {
if (dir === '') return;
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
export async function exists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,17 @@
type ObjectLiteral = { [key: string | symbol]: unknown };
/**
* Checks if the provided value is a plain object literal (not null, not an array, not a class instance, and not a primitive).
* This function serves as a type guard.
*
* @param candidate - The value to check
* @returns {boolean} True if the value is an object literal, false otherwise
*/
export function isObjectLiteral(candidate: unknown): candidate is ObjectLiteral {
return (
typeof candidate === 'object' &&
candidate !== null &&
!Array.isArray(candidate) &&
(Object.getPrototypeOf(candidate) as object)?.constructor?.name === 'Object'
);
}
@@ -0,0 +1,20 @@
import { parse } from 'flatted';
import { parseFlattedAsync } from './flatted-async';
// 1 MB — below this, sync parse is fast enough
export const SIZE_THRESHOLD = 1 * 1024 * 1024;
/**
* Parse a flatted JSON string, using async streaming for large payloads
* and sync parsing for small ones.
*
* @param data - The flatted JSON string to parse
* @returns The deserialized object
*/
export async function parseFlatted(data: string): Promise<unknown> {
if (data.length < SIZE_THRESHOLD) {
return parse(data);
}
return await parseFlattedAsync(data);
}
@@ -0,0 +1,36 @@
import { UnexpectedError } from 'n8n-workflow';
import * as path from 'node:path';
/**
* Checks if the given childPath is contained within the parentPath. Resolves
* the paths before comparing them, so that relative paths are also supported.
*/
export function isContainedWithin(parentPath: string, childPath: string): boolean {
parentPath = path.resolve(parentPath);
childPath = path.resolve(childPath);
if (parentPath === childPath) {
return true;
}
return childPath.startsWith(parentPath + path.sep);
}
/**
* Joins the given paths to the parentPath, ensuring that the resulting path
* is still contained within the parentPath. If not, it throws an error to
* prevent path traversal vulnerabilities.
*
* @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
*/
export function safeJoinPath(parentPath: string, ...paths: string[]): string {
const candidate = path.join(parentPath, ...paths);
if (!isContainedWithin(parentPath, candidate)) {
throw new UnexpectedError(
`Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`,
);
}
return candidate;
}
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
}
@@ -0,0 +1,19 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../workflow/tsconfig.build.cjs.json" },
{ "path": "../config/tsconfig.build.json" },
{ "path": "../constants/tsconfig.build.json" },
{ "path": "../decorators/tsconfig.build.json" },
{ "path": "../di/tsconfig.build.json" }
]
}