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
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:
@@ -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 }],
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user