diff --git a/.dockerignore b/.dockerignore index 64039c9cdb8..ca895123698 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,3 +38,4 @@ !packages/@n8n/benchmark/** !packages/@n8n/typescript-config !packages/@n8n/typescript-config/** + diff --git a/docker/images/engine/Dockerfile b/docker/images/engine/Dockerfile new file mode 100644 index 00000000000..71a695ac541 --- /dev/null +++ b/docker/images/engine/Dockerfile @@ -0,0 +1,20 @@ +ARG NODE_VERSION=24.14.1 + +FROM node:${NODE_VERSION}-alpine3.22 + +ENV NODE_ENV=production + +RUN apk add --no-cache tini + +WORKDIR /app + +# `compiled/` is produced by `pnpm build:docker`. It's a `pnpm deploy --prod` +# output containing package.json, dist/, and a node_modules with only +# production dependencies — no devDeps, no workspace bloat. +COPY --chown=node:node ./compiled /app + +USER node +EXPOSE 3000 + +ENTRYPOINT ["tini", "--"] +CMD ["node", "dist/serve.js"] diff --git a/packages/@n8n/config/src/configs/engine.config.ts b/packages/@n8n/config/src/configs/engine.config.ts new file mode 100644 index 00000000000..f2ad930b83d --- /dev/null +++ b/packages/@n8n/config/src/configs/engine.config.ts @@ -0,0 +1,12 @@ +import { Config, Env } from '../decorators'; + +@Config +export class EngineConfig { + /** Port the engine HTTP server listens on. */ + @Env('N8N_ENGINE_PORT') + port: number = 3000; + + /** Host interface the engine HTTP server binds to. */ + @Env('N8N_ENGINE_HOST') + host: string = '0.0.0.0'; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index fc6891b1b05..0d82f26339c 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -61,6 +61,7 @@ export { SsrfProtectionConfig, SSRF_DEFAULT_BLOCKED_IP_RANGES, } from './configs/ssrf-protection.config'; +export { EngineConfig } from './configs/engine.config'; export { ExecutionsConfig } from './configs/executions.config'; export { LOG_SCOPES } from './configs/logging.config'; export type { LogScope } from './configs/logging.config'; diff --git a/packages/@n8n/engine/.dockerignore b/packages/@n8n/engine/.dockerignore new file mode 100644 index 00000000000..b22530cf13d --- /dev/null +++ b/packages/@n8n/engine/.dockerignore @@ -0,0 +1,3 @@ +* +!compiled +!compiled/** diff --git a/packages/@n8n/engine/eslint.config.mjs b/packages/@n8n/engine/eslint.config.mjs index f97402009c5..b0c843929f3 100644 --- a/packages/@n8n/engine/eslint.config.mjs +++ b/packages/@n8n/engine/eslint.config.mjs @@ -1,4 +1,7 @@ import { defineConfig } from 'eslint/config'; import { nodeConfig } from '@n8n/eslint-config/node'; -export default defineConfig(nodeConfig); +export default defineConfig( + { ignores: ['compiled/**', 'vitest.integration.config.ts'] }, + nodeConfig, +); diff --git a/packages/@n8n/engine/package.json b/packages/@n8n/engine/package.json index f08a21a18c9..8c69748424c 100644 --- a/packages/@n8n/engine/package.json +++ b/packages/@n8n/engine/package.json @@ -3,15 +3,18 @@ "version": "0.1.0", "description": "n8n workflow execution engine (v2)", "scripts": { - "clean": "rimraf dist .turbo", + "clean": "rimraf dist .turbo compiled", "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json", + "build:docker": "pnpm build && rimraf compiled && DOCKER_BUILD=true NODE_ENV=production pnpm --filter . --prod --legacy deploy --no-optional compiled && docker build -f ../../../docker/images/engine/Dockerfile -t n8n-engine:local .", + "start": "node dist/serve.js", "format": "biome format --write src", "format:check": "biome ci src", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "test": "vitest run --passWithNoTests", "test:dev": "vitest --watch", + "test:integration": "vitest run --config vitest.integration.config.ts", "watch": "tsc -p tsconfig.build.json --watch" }, "main": "dist/index.js", @@ -19,10 +22,18 @@ "files": [ "dist/**/*" ], - "dependencies": {}, + "dependencies": { + "@n8n/config": "workspace:*", + "@n8n/di": "workspace:*", + "express": "5.1.0", + "reflect-metadata": "catalog:" + }, "devDependencies": { "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", + "@types/express": "catalog:", + "@types/supertest": "^6.0.3", + "supertest": "^7.1.1", "vitest": "catalog:" } } diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts index 81901f68e5a..9a9173920ae 100644 --- a/packages/@n8n/engine/src/index.ts +++ b/packages/@n8n/engine/src/index.ts @@ -1,5 +1 @@ -// Public API of @n8n/engine. -// -// Intentionally empty for now. The StartExecution API surface and core engine -// interfaces will land in subsequent CAT-2859 sub-tickets. -export {}; +export { createEngineServer } from './server'; diff --git a/packages/@n8n/engine/src/serve.ts b/packages/@n8n/engine/src/serve.ts new file mode 100644 index 00000000000..7b02f450430 --- /dev/null +++ b/packages/@n8n/engine/src/serve.ts @@ -0,0 +1,29 @@ +import { EngineConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; + +import { createEngineServer } from './server'; + +const config = Container.get(EngineConfig); + +const { app } = createEngineServer(); + +const server = app.listen(config.port, config.host, () => { + console.log(`engine: listening on http://${config.host}:${config.port}`); +}); + +let shuttingDown = false; +const shutdown = (signal: string): void => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`engine: received ${signal}, shutting down`); + server.close((error) => { + if (error) { + console.error('engine: error during shutdown', error); + process.exit(1); + } + process.exit(0); + }); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/packages/@n8n/engine/src/server/create-engine-server.ts b/packages/@n8n/engine/src/server/create-engine-server.ts new file mode 100644 index 00000000000..61a99a0d7b6 --- /dev/null +++ b/packages/@n8n/engine/src/server/create-engine-server.ts @@ -0,0 +1,11 @@ +import express, { type Application } from 'express'; + +export function createEngineServer(): { app: Application } { + const app = express(); + + app.get('/healthz', (_req, res) => { + res.status(200).json({ status: 'ok' }); + }); + + return { app }; +} diff --git a/packages/@n8n/engine/src/server/index.ts b/packages/@n8n/engine/src/server/index.ts new file mode 100644 index 00000000000..b68b301130a --- /dev/null +++ b/packages/@n8n/engine/src/server/index.ts @@ -0,0 +1 @@ +export { createEngineServer } from './create-engine-server'; diff --git a/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts b/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts new file mode 100644 index 00000000000..2a00e01338e --- /dev/null +++ b/packages/@n8n/engine/src/testing/__tests__/start-engine-server.integration.test.ts @@ -0,0 +1,24 @@ +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startEngineServer } from '../start-engine-server'; + +describe('engine HTTP server (e2e)', () => { + let url: string; + let stop: () => Promise; + + beforeAll(async () => { + ({ url, stop } = await startEngineServer()); + }); + + afterAll(async () => { + await stop(); + }); + + it('responds to GET /healthz with { status: "ok" }', async () => { + const response = await request(url).get('/healthz'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); +}); diff --git a/packages/@n8n/engine/src/testing/index.ts b/packages/@n8n/engine/src/testing/index.ts new file mode 100644 index 00000000000..ff4242e111c --- /dev/null +++ b/packages/@n8n/engine/src/testing/index.ts @@ -0,0 +1 @@ +export { startEngineServer } from './start-engine-server'; diff --git a/packages/@n8n/engine/src/testing/start-engine-server.ts b/packages/@n8n/engine/src/testing/start-engine-server.ts new file mode 100644 index 00000000000..db1bc9090ea --- /dev/null +++ b/packages/@n8n/engine/src/testing/start-engine-server.ts @@ -0,0 +1,33 @@ +import type { Server } from 'node:http'; + +import { createEngineServer } from '../server'; + +export async function startEngineServer(): Promise<{ + url: string; + stop: () => Promise; +}> { + const { app } = createEngineServer(); + + const server = await new Promise((resolve, reject) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + s.on('error', reject); + }); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Engine server address is not a TCP socket'); + } + + const url = `http://127.0.0.1:${address.port}`; + + const stop = async (): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + }; + + return { url, stop }; +} diff --git a/packages/@n8n/engine/tsconfig.json b/packages/@n8n/engine/tsconfig.json index 9427943ed42..e54b3d13fa4 100644 --- a/packages/@n8n/engine/tsconfig.json +++ b/packages/@n8n/engine/tsconfig.json @@ -7,7 +7,9 @@ "target": "es2023", "lib": ["es2023"], "types": ["node"], - "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" + "tsBuildInfoFile": "dist/typecheck.tsbuildinfo", + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["src/**/*.ts"] } diff --git a/packages/@n8n/engine/vitest.config.ts b/packages/@n8n/engine/vitest.config.ts index f8e3113a59e..a48d31846bf 100644 --- a/packages/@n8n/engine/vitest.config.ts +++ b/packages/@n8n/engine/vitest.config.ts @@ -1,3 +1,5 @@ import { createVitestConfig } from '@n8n/vitest-config/node'; -export default createVitestConfig(); +export default createVitestConfig({ + exclude: ['**/node_modules/**', '**/dist/**', '**/*.integration.test.ts'], +}); diff --git a/packages/@n8n/engine/vitest.integration.config.ts b/packages/@n8n/engine/vitest.integration.config.ts new file mode 100644 index 00000000000..9fef0533d3e --- /dev/null +++ b/packages/@n8n/engine/vitest.integration.config.ts @@ -0,0 +1,5 @@ +import { createVitestConfig } from '@n8n/vitest-config/node'; + +export default createVitestConfig({ + include: ['**/*.integration.test.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c488d3d041..193c0edbefd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1399,6 +1399,19 @@ importers: version: link:../typescript-config packages/@n8n/engine: + dependencies: + '@n8n/config': + specifier: workspace:* + version: link:../config + '@n8n/di': + specifier: workspace:* + version: link:../di + express: + specifier: 5.1.0 + version: 5.1.0 + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 devDependencies: '@n8n/typescript-config': specifier: workspace:* @@ -1406,6 +1419,15 @@ importers: '@n8n/vitest-config': specifier: workspace:* version: link:../vitest-config + '@types/express': + specifier: 'catalog:' + version: 5.0.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + supertest: + specifier: ^7.1.1 + version: 7.1.1 vitest: specifier: 'catalog:' version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) @@ -17883,10 +17905,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -33948,7 +33966,7 @@ snapshots: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3(supports-color@8.1.1) - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.14.2 @@ -36543,15 +36561,15 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 finalhandler: 2.1.0 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 2.0.0 - mime-types: 3.0.1 + mime-types: 3.0.2 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 @@ -36561,7 +36579,7 @@ snapshots: router: 2.2.0 send: 1.2.0 serve-static: 2.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: @@ -36802,7 +36820,7 @@ snapshots: escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -40077,10 +40095,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.1: - dependencies: - mime-db: 1.54.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -43187,12 +43201,12 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color