diff --git a/packages/cli/package.json b/packages/cli/package.json index 4265dafbe1..c3b0e2d3f2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -85,6 +85,7 @@ "@types/parseurl": "^1.3.1", "@types/passport-jwt": "^3.0.6", "@types/psl": "^1.1.0", + "@types/replacestream": "^4.0.1", "@types/send": "^0.17.1", "@types/shelljs": "^0.8.11", "@types/superagent": "4.1.13", @@ -168,6 +169,7 @@ "posthog-node": "^1.3.0", "prom-client": "^13.1.0", "psl": "^1.8.0", + "replacestream": "^4.0.3", "semver": "^7.3.8", "shelljs": "^0.8.5", "sqlite3": "^5.1.2", diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index f4d8eb6daf..618cd0f5e7 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -15,13 +15,13 @@ import type { } from 'n8n-workflow'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { createWriteStream } from 'fs'; import { access as fsAccess, copyFile, mkdir, readdir as fsReaddir, stat as fsStat, - writeFile, } from 'fs/promises'; import path from 'path'; import config from '@/config'; @@ -76,10 +76,17 @@ export class LoadNodesAndCredentialsClass implements INodesAndCredentials { // pre-render all the node and credential types as static json files await mkdir(path.join(GENERATED_STATIC_DIR, 'types'), { recursive: true }); - const writeStaticJSON = async (name: string, data: any[]) => { + const writeStaticJSON = async (name: string, data: object[]) => { const filePath = path.join(GENERATED_STATIC_DIR, `types/${name}.json`); - const payload = `[\n${data.map((entry) => JSON.stringify(entry)).join(',\n')}\n]`; - await writeFile(filePath, payload, { encoding: 'utf-8' }); + const stream = createWriteStream(filePath, 'utf-8'); + stream.write('[\n'); + data.forEach((entry, index) => { + stream.write(JSON.stringify(entry)); + if (index !== data.length - 1) stream.write(','); + stream.write('\n'); + }); + stream.write(']\n'); + stream.end(); }; await writeStaticJSON('nodes', this.types.nodes); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 250c5e175e..13b11bc6dc 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -28,10 +28,10 @@ /* eslint-disable no-await-in-loop */ import { exec as callbackExec } from 'child_process'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { access as fsAccess, readFile, writeFile, mkdir } from 'fs/promises'; +import { readFileSync } from 'fs'; +import { access as fsAccess } from 'fs/promises'; import os from 'os'; -import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path'; +import { join as pathJoin, resolve as pathResolve } from 'path'; import { createHmac } from 'crypto'; import { promisify } from 'util'; import cookieParser from 'cookie-parser'; @@ -78,7 +78,6 @@ import parseUrl from 'parseurl'; import promClient, { Registry } from 'prom-client'; import history from 'connect-history-api-fallback'; import bodyParser from 'body-parser'; -import glob from 'fast-glob'; import config from '@/config'; import * as Queue from '@/Queue'; @@ -91,6 +90,7 @@ import { nodesController } from '@/api/nodes.api'; import { workflowsController } from '@/workflows/workflows.controller'; import { AUTH_COOKIE_NAME, + EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR, NODES_BASE_DIR, RESPONSE_ERROR_MESSAGES, @@ -113,7 +113,6 @@ import { executionsController } from '@/api/executions.api'; import { nodeTypesController } from '@/api/nodeTypes.api'; import { tagsController } from '@/api/tags.api'; import { loadPublicApiVersions } from '@/PublicApi'; -import * as telemetryScripts from '@/telemetry/scripts'; import { getInstanceBaseUrl, isEmailSetUp, @@ -1640,56 +1639,7 @@ class App { } if (!config.getEnv('endpoints.disableUi')) { - // Read the index file and replace the path placeholder - const n8nPath = config.getEnv('path'); - const basePathRegEx = /\/{{BASE_PATH}}\//g; - const hooksUrls = config.getEnv('externalFrontendHooksUrls'); - - let scriptsString = ''; - if (hooksUrls) { - scriptsString = hooksUrls.split(';').reduce((acc, curr) => { - return `${acc}`; - }, ''); - } - - if (this.frontendSettings.telemetry.enabled) { - const phLoadingScript = telemetryScripts.createPostHogLoadingScript({ - apiKey: config.getEnv('diagnostics.config.posthog.apiKey'), - apiHost: config.getEnv('diagnostics.config.posthog.apiHost'), - autocapture: false, - disableSessionRecording: config.getEnv( - 'diagnostics.config.posthog.disableSessionRecording', - ), - debug: config.getEnv('logs.level') === 'debug', - }); - - scriptsString += phLoadingScript; - } - - const editorUiDistDir = pathJoin(pathDirname(require.resolve('n8n-editor-ui')), 'dist'); - - const closingTitleTag = ''; - const compileFile = async (fileName: string) => { - const filePath = pathJoin(editorUiDistDir, fileName); - if (/(index\.html)|.*\.(js|css)/.test(filePath) && existsSync(filePath)) { - const srcFile = await readFile(filePath, 'utf8'); - let payload = srcFile - .replace(basePathRegEx, n8nPath) - .replace(/\/static\//g, n8nPath + 'static/'); - if (filePath.endsWith('index.html')) { - payload = payload.replace(closingTitleTag, closingTitleTag + scriptsString); - } - const destFile = pathJoin(GENERATED_STATIC_DIR, fileName); - await mkdir(pathDirname(destFile), { recursive: true }); - await writeFile(destFile, payload, 'utf-8'); - } - }; - - await compileFile('index.html'); - const files = await glob('**/*.{css,js}', { cwd: editorUiDistDir }); - await Promise.all(files.map(compileFile)); - - this.app.use('/', express.static(GENERATED_STATIC_DIR), express.static(editorUiDistDir)); + this.app.use('/', express.static(GENERATED_STATIC_DIR), express.static(EDITOR_UI_DIST_DIR)); const startTime = new Date().toUTCString(); this.app.use('/index.html', (req, res, next) => { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index fb2acba890..9d9e084a76 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -6,10 +6,17 @@ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import path from 'path'; +import { mkdir } from 'fs/promises'; +import { createReadStream, createWriteStream, existsSync } from 'fs'; import localtunnel from 'localtunnel'; import { BinaryDataManager, TUNNEL_SUBDOMAIN_ENV, UserSettings } from 'n8n-core'; import { Command, flags } from '@oclif/command'; import Redis from 'ioredis'; +import stream from 'stream'; +import replaceStream from 'replacestream'; +import { promisify } from 'util'; +import glob from 'fast-glob'; import { IDataObject, LoggerProxy, sleep } from 'n8n-workflow'; import { createHash } from 'crypto'; @@ -34,9 +41,12 @@ import { getLogger } from '@/Logger'; import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; import { initErrorHandling } from '@/ErrorReporting'; import * as CrashJournal from '@/CrashJournal'; +import { createPostHogLoadingScript } from '@/telemetry/scripts'; +import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); +const pipeline = promisify(stream.pipeline); let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; let processExitCode = 0; @@ -152,6 +162,60 @@ export class Start extends Command { exit(); } + static async generateStaticAssets() { + // Read the index file and replace the path placeholder + const n8nPath = config.getEnv('path'); + const hooksUrls = config.getEnv('externalFrontendHooksUrls'); + + let scriptsString = ''; + if (hooksUrls) { + scriptsString = hooksUrls.split(';').reduce((acc, curr) => { + return `${acc}`; + }, ''); + } + + if (config.getEnv('diagnostics.enabled')) { + const phLoadingScript = createPostHogLoadingScript({ + apiKey: config.getEnv('diagnostics.config.posthog.apiKey'), + apiHost: config.getEnv('diagnostics.config.posthog.apiHost'), + autocapture: false, + disableSessionRecording: config.getEnv( + 'diagnostics.config.posthog.disableSessionRecording', + ), + debug: config.getEnv('logs.level') === 'debug', + }); + + scriptsString += phLoadingScript; + } + + const closingTitleTag = ''; + const compileFile = async (fileName: string) => { + const filePath = path.join(EDITOR_UI_DIST_DIR, fileName); + if (/(index\.html)|.*\.(js|css)/.test(filePath) && existsSync(filePath)) { + const destFile = path.join(GENERATED_STATIC_DIR, fileName); + await mkdir(path.dirname(destFile), { recursive: true }); + const streams = [ + createReadStream(filePath, 'utf-8'), + replaceStream('/{{BASE_PATH}}/', n8nPath, { ignoreCase: false }), + replaceStream('/static/', n8nPath + 'static/', { ignoreCase: false }), + ]; + if (filePath.endsWith('index.html')) { + streams.push( + replaceStream(closingTitleTag, closingTitleTag + scriptsString, { + ignoreCase: false, + }), + ); + } + streams.push(createWriteStream(destFile, 'utf-8')); + return pipeline(streams); + } + }; + + await compileFile('index.html'); + const files = await glob('**/*.{css,js}', { cwd: EDITOR_UI_DIST_DIR }); + await Promise.all(files.map(compileFile)); + } + async run() { // Make sure that n8n shuts down gracefully if possible process.once('SIGTERM', Start.stopProcess); @@ -200,6 +264,10 @@ export class Start extends Command { ); } + if (!config.getEnv('endpoints.disableUi')) { + await Start.generateStaticAssets(); + } + // Load all node and credential types const loadNodesAndCredentials = LoadNodesAndCredentials(); await loadNodesAndCredentials.init(); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 749d82135f..b01602bbca 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/naming-convention */ -import { resolve, join } from 'path'; +import { resolve, join, dirname } from 'path'; import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES, UserSettings } from 'n8n-core'; export const CLI_DIR = resolve(__dirname, '..'); export const TEMPLATES_DIR = join(CLI_DIR, 'templates'); export const NODES_BASE_DIR = join(CLI_DIR, '..', 'nodes-base'); export const GENERATED_STATIC_DIR = join(UserSettings.getUserHome(), '.cache/n8n/public'); +export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist'); export const NODE_PACKAGE_PREFIX = 'n8n-nodes-'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bcd72454c..7e0dfb76de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,7 @@ importers: '@types/parseurl': ^1.3.1 '@types/passport-jwt': ^3.0.6 '@types/psl': ^1.1.0 + '@types/replacestream': ^4.0.1 '@types/send': ^0.17.1 '@types/shelljs': ^0.8.11 '@types/superagent': 4.1.13 @@ -193,6 +194,7 @@ importers: posthog-node: ^1.3.0 prom-client: ^13.1.0 psl: ^1.8.0 + replacestream: ^4.0.3 run-script-os: ^1.0.7 semver: ^7.3.8 shelljs: ^0.8.5 @@ -276,6 +278,7 @@ importers: posthog-node: 1.3.0 prom-client: 13.2.0 psl: 1.9.0 + replacestream: 4.0.3 semver: 7.3.8 shelljs: 0.8.5 sqlite3: 5.1.2 @@ -312,6 +315,7 @@ importers: '@types/parseurl': 1.3.1 '@types/passport-jwt': 3.0.7 '@types/psl': 1.1.0 + '@types/replacestream': 4.0.1 '@types/send': 0.17.1 '@types/shelljs': 0.8.11 '@types/superagent': 4.1.13 @@ -5990,6 +5994,10 @@ packages: '@types/node': 16.11.65 dev: true + /@types/replacestream/4.0.1: + resolution: {integrity: sha512-3ecTmnzB90sgarVpIszCF1cX2cnxwqDovWb31jGrKfxAL0Knui1H7Reaz/zlT9zaE3u0un7L5cNy9fQPy0d2sg==} + dev: true + /@types/request-promise-native/1.0.18: resolution: {integrity: sha512-tPnODeISFc/c1LjWyLuZUY+Z0uLB3+IMfNoQyDEi395+j6kTFTTRAqjENjoPJUid4vHRGEozoTrcTrfZM+AcbA==} dependencies: @@ -18464,6 +18472,14 @@ packages: yargs: 17.6.0 dev: false + /replacestream/4.0.3: + resolution: {integrity: sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA==} + dependencies: + escape-string-regexp: 1.0.5 + object-assign: 4.1.1 + readable-stream: 2.3.7 + dev: false + /request-progress/3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} dependencies: