From 4a21e4e48eeac3054c9eb88964b8119370e2a2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 11:19:09 +0200 Subject: [PATCH 1/8] Move to controller --- packages/cli/src/Server.ts | 49 +-------------- .../src/controllers/binaryData.controller.ts | 61 +++++++++++++++++++ .../editor-ui/src/stores/workflows.store.ts | 2 +- 3 files changed, 64 insertions(+), 48 deletions(-) create mode 100644 packages/cli/src/controllers/binaryData.controller.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ddde98bc89313..0ad5a684ddb0c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -32,7 +32,6 @@ import { LoadNodeParameterOptions, LoadNodeListSearch, UserSettings, - FileNotFoundError, } from 'n8n-core'; import type { @@ -74,7 +73,6 @@ import { import { credentialsController } from '@/credentials/credentials.controller'; import { oauth2CredentialController } from '@/credentials/oauth2Credential.api'; import type { - BinaryDataRequest, CurlHelper, ExecutionRequest, NodeListSearchRequest, @@ -99,6 +97,7 @@ import { WorkflowStatisticsController, } from '@/controllers'; +import { BinaryDataController } from './controllers/binaryData.controller'; import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; import { executionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; @@ -374,7 +373,6 @@ export class Server extends AbstractServer { this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); this.push = Container.get(Push); - this.binaryDataService = Container.get(BinaryDataService); await super.start(); LoggerProxy.debug(`Server ID: ${this.uniqueInstanceId}`); @@ -581,6 +579,7 @@ export class Server extends AbstractServer { Container.get(ExternalSecretsController), Container.get(OrchestrationController), Container.get(WorkflowHistoryController), + new BinaryDataController(Container.get(BinaryDataService)), ]; if (isLdapEnabled()) { @@ -1442,50 +1441,6 @@ export class Server extends AbstractServer { }), ); - // ---------------------------------------- - // Binary data - // ---------------------------------------- - - // View or download binary file - this.app.get( - `/${this.restEndpoint}/data`, - async (req: BinaryDataRequest, res: express.Response): Promise => { - const { id: binaryDataId, action } = req.query; - let { fileName, mimeType } = req.query; - const [mode] = binaryDataId.split(':') as ['filesystem' | 's3', string]; - - try { - const binaryPath = this.binaryDataService.getPath(binaryDataId); - - if (!fileName || !mimeType) { - try { - const metadata = await this.binaryDataService.getMetadata(binaryDataId); - fileName = metadata.fileName; - mimeType = metadata.mimeType; - res.setHeader('Content-Length', metadata.fileSize); - } catch {} - } - - if (mimeType) res.setHeader('Content-Type', mimeType); - - if (action === 'download') { - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - } - - if (mode === 's3') { - const readStream = await this.binaryDataService.getAsStream(binaryDataId); - readStream.pipe(res); - return; - } else { - res.sendFile(binaryPath); - } - } catch (error) { - if (error instanceof FileNotFoundError) res.writeHead(404).end(); - else throw error; - } - }, - ); - // ---------------------------------------- // Settings // ---------------------------------------- diff --git a/packages/cli/src/controllers/binaryData.controller.ts b/packages/cli/src/controllers/binaryData.controller.ts new file mode 100644 index 0000000000000..9175a9977911c --- /dev/null +++ b/packages/cli/src/controllers/binaryData.controller.ts @@ -0,0 +1,61 @@ +import { createReadStream } from 'node:fs'; +import { Service } from 'typedi'; +import express from 'express'; +import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core'; +import { Get, RestController } from '@/decorators'; +import { BinaryDataRequest } from '@/requests'; + +@RestController('/binary-data') +@Service() +export class BinaryDataController { + constructor(private readonly binaryDataService: BinaryDataService) {} + + @Get('/') + async get(req: BinaryDataRequest, res: express.Response) { + const { id: binaryDataId, action } = req.query; + + if (!binaryDataId) { + return res.status(400).end('Missing binary data ID'); + } + + if (!binaryDataId.includes(':')) { + return res.status(400).end('Missing binary data mode'); + } + + const [mode] = binaryDataId.split(':'); + + if (!isValidNonDefaultMode(mode)) { + return res.status(400).end('Invalid binary data mode'); + } + + let { fileName, mimeType } = req.query; + + try { + const binaryFilePath = this.binaryDataService.getPath(binaryDataId); + + if (!fileName || !mimeType) { + try { + const metadata = await this.binaryDataService.getMetadata(binaryDataId); + fileName = metadata.fileName; + mimeType = metadata.mimeType; + res.setHeader('Content-Length', metadata.fileSize); + } catch {} + } + + if (mimeType) res.setHeader('Content-Type', mimeType); + + if (action === 'download') { + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + } + + if (mode === 's3') { + return await this.binaryDataService.getAsStream(binaryDataId); + } else { + return createReadStream(binaryFilePath); + } + } catch (error) { + if (error instanceof FileNotFoundError) return res.writeHead(404).end(); + else throw error; + } + } +} diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index e41696f9c5d7a..2ac885d7f461d 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1393,7 +1393,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { const rootStore = useRootStore(); let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; - const url = new URL(`${restUrl}/data`); + const url = new URL(`${restUrl}/binary-data`); url.searchParams.append('id', binaryDataId); url.searchParams.append('action', action); if (fileName) url.searchParams.append('fileName', fileName); From a6ed8972094917ff126e1ee7d93f15a6b7b3df6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 11:19:16 +0200 Subject: [PATCH 2/8] Add support for streaming --- packages/cli/src/ResponseHelper.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 0d87b77f644f1..48a0cccc93ca8 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -6,7 +6,7 @@ import type { Request, Response } from 'express'; import { parse, stringify } from 'flatted'; import picocolors from 'picocolors'; import { ErrorReporterProxy as ErrorReporter, NodeApiError } from 'n8n-workflow'; - +import { Stream } from 'node:stream'; import type { IExecutionDb, IExecutionFlatted, @@ -101,6 +101,11 @@ export function sendSuccessResponse( res.header(responseHeader); } + if (data instanceof Stream) { + data.pipe(res); + return; + } + if (raw === true) { if (typeof data === 'string') { res.send(data); From 8f1299f0b50abd1c79e627fd34120e67f08a220d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 11:19:29 +0200 Subject: [PATCH 3/8] Export util --- packages/core/src/BinaryData/utils.ts | 4 ++++ packages/core/src/index.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 715456a2e5339..96eeb9fbfe5b9 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -15,6 +15,10 @@ export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } +export function isValidNonDefaultMode(mode: string): mode is BinaryData.NonDefaultMode { + return BINARY_DATA_MODES.filter((m) => m !== 'default').includes(mode as BinaryData.Mode); +} + export async function ensureDirExists(dir: string) { try { await fs.access(dir); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3eaecf6581d84..3b39ec9bad709 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,3 +18,4 @@ export { NodeExecuteFunctions, UserSettings }; export * from './errors'; export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee'; export { BinaryData } from './BinaryData/types'; +export { isValidNonDefaultMode } from './BinaryData/utils'; From cbcee5abe1ecaac12b7cffbd7c701436fb8fe948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 11:19:34 +0200 Subject: [PATCH 4/8] Add tests --- .../test/integration/binaryData.api.test.ts | 150 ++++++++++++++++++ packages/cli/test/integration/shared/types.ts | 3 +- .../integration/shared/utils/testServer.ts | 4 + 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/integration/binaryData.api.test.ts diff --git a/packages/cli/test/integration/binaryData.api.test.ts b/packages/cli/test/integration/binaryData.api.test.ts new file mode 100644 index 0000000000000..9492849fcec2f --- /dev/null +++ b/packages/cli/test/integration/binaryData.api.test.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import { BinaryDataService, FileNotFoundError } from 'n8n-core'; +import * as testDb from './shared/testDb'; +import { mockInstance, setupTestServer } from './shared/utils'; +import type { SuperAgentTest } from 'supertest'; + +jest.mock('fs', () => { + return { + ...jest.requireActual('fs'), + createReadStream: jest.fn(), + }; +}); + +jest.mock('fs/promises'); + +const throwFileNotFound = () => { + throw new FileNotFoundError('non/existing/path'); +}; + +const binaryDataService = mockInstance(BinaryDataService); +let testServer = setupTestServer({ endpointGroups: ['binaryData'] }); +let authOwnerAgent: SuperAgentTest; + +beforeAll(async () => { + const owner = await testDb.createOwner(); + authOwnerAgent = testServer.authAgentFor(owner); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('GET /binary-data', () => { + const fileId = '599c5f84007-7d14-4b63-8f1e-d726098d0cc0'; + const fsBinaryDataId = `filesystem:${fileId}`; + const s3BinaryDataId = `s3:${fileId}`; + const binaryFilePath = `/Users/john/.n8n/binaryData/${fileId}`; + const mimeType = 'text/plain'; + const fileName = 'test.txt'; + const buffer = Buffer.from('content'); + const bufferResponse = '{"data":{"type":"Buffer","data":[99,111,110,116,101,110,116]}}'; + + describe('should reject on missing or invalid binary data ID', () => { + test.each([['view'], ['download']])('on request to %s', async (action) => { + binaryDataService.getPath.mockReturnValue(binaryFilePath); + fsp.readFile = jest.fn().mockResolvedValue(buffer); + + await authOwnerAgent + .get('/binary-data') + .query({ + fileName, + mimeType, + action, + }) + .expect(400); + + await authOwnerAgent + .get('/binary-data') + .query({ + id: 'invalid', + fileName, + mimeType, + action, + }) + .expect(400); + }); + }); + + describe('should return binary data [filesystem]', () => { + test.each([['view'], ['download']])('on request to %s', async (action) => { + binaryDataService.getPath.mockReturnValue(binaryFilePath); + fs.createReadStream = jest.fn().mockReturnValue(buffer); + + const res = await authOwnerAgent + .get('/binary-data') + .query({ + id: fsBinaryDataId, + fileName, + mimeType, + action, + }) + .expect(200); + + const contentDisposition = + action === 'download' ? `attachment; filename="${fileName}"` : undefined; + + expect(res.text).toBe(bufferResponse); + expect(res.headers['content-type']).toBe(mimeType + '; charset=utf-8'); + expect(res.headers['content-length']).toBe(bufferResponse.length.toString()); + expect(res.headers['content-disposition']).toBe(contentDisposition); + }); + }); + + describe('should return 404 on file not found [filesystem]', () => { + test.each(['view', 'download'])('on request to %s', async (action) => { + binaryDataService.getPath.mockImplementation(throwFileNotFound); + + await authOwnerAgent + .get('/binary-data') + .query({ + id: fsBinaryDataId, + fileName, + mimeType, + action, + }) + .expect(404); + }); + }); + + describe('should return binary data [s3]', () => { + test.each([['view'], ['download']])('on request to %s', async (action) => { + binaryDataService.getPath.mockReturnValue(binaryFilePath); + + const res = await authOwnerAgent + .get('/binary-data') + .query({ + id: s3BinaryDataId, + fileName, + mimeType, + action, + }) + .expect(200); + + expect(binaryDataService.getAsStream).toHaveBeenCalledWith(s3BinaryDataId); + + const contentDisposition = + action === 'download' ? `attachment; filename="${fileName}"` : undefined; + + expect(res.headers['content-type']).toBe(mimeType + '; charset=utf-8'); + expect(res.headers['content-disposition']).toBe(contentDisposition); + }); + }); + + describe('should return 404 on file not found [s3]', () => { + test.each(['view', 'download'])('on request to %s', async (action) => { + binaryDataService.getPath.mockImplementation(throwFileNotFound); + + await authOwnerAgent + .get('/binary-data') + .query({ + id: s3BinaryDataId, + fileName, + mimeType, + action, + }) + .expect(404); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 233392bfd7fa8..f100daf4aeeab 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -34,7 +34,8 @@ export type EndpointGroup = | 'mfa' | 'metrics' | 'executions' - | 'workflowHistory'; + | 'workflowHistory' + | 'binaryData'; export interface SetupProps { applyAuth?: boolean; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 356861d710fac..6bcbc60bcc2da 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -66,6 +66,7 @@ import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; import { executionsController } from '@/executions/executions.controller'; import { WorkflowHistoryController } from '@/workflows/workflowHistory/workflowHistory.controller.ee'; +import { BinaryDataController } from '@/controllers/binaryData.controller'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -316,6 +317,9 @@ export const setupTestServer = ({ case 'workflowHistory': registerController(app, config, Container.get(WorkflowHistoryController)); break; + case 'binaryData': + registerController(app, config, Container.get(BinaryDataController)); + break; } } } From 3f9ecab01221f5ef6d27cab3dccaf4edb79c75c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 14:18:12 +0200 Subject: [PATCH 5/8] Simplify call --- packages/cli/src/Server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 0ad5a684ddb0c..3960ea15f2597 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -25,8 +25,8 @@ import axios from 'axios'; import type { RequestOptions } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; +import type { BinaryDataService } from 'n8n-core'; import { - BinaryDataService, Credentials, LoadMappingOptions, LoadNodeParameterOptions, @@ -579,7 +579,7 @@ export class Server extends AbstractServer { Container.get(ExternalSecretsController), Container.get(OrchestrationController), Container.get(WorkflowHistoryController), - new BinaryDataController(Container.get(BinaryDataService)), + Container.get(BinaryDataController), ]; if (isLdapEnabled()) { From 893eaf1457dda14848eae47455d76195829e3ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 14:39:03 +0200 Subject: [PATCH 6/8] Do not leak implementation detail --- .../src/controllers/binaryData.controller.ts | 9 +----- .../test/integration/binaryData.api.test.ts | 29 +++++++------------ 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/controllers/binaryData.controller.ts b/packages/cli/src/controllers/binaryData.controller.ts index 9175a9977911c..00f6a85942cec 100644 --- a/packages/cli/src/controllers/binaryData.controller.ts +++ b/packages/cli/src/controllers/binaryData.controller.ts @@ -1,4 +1,3 @@ -import { createReadStream } from 'node:fs'; import { Service } from 'typedi'; import express from 'express'; import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core'; @@ -31,8 +30,6 @@ export class BinaryDataController { let { fileName, mimeType } = req.query; try { - const binaryFilePath = this.binaryDataService.getPath(binaryDataId); - if (!fileName || !mimeType) { try { const metadata = await this.binaryDataService.getMetadata(binaryDataId); @@ -48,11 +45,7 @@ export class BinaryDataController { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - if (mode === 's3') { - return await this.binaryDataService.getAsStream(binaryDataId); - } else { - return createReadStream(binaryFilePath); - } + return await this.binaryDataService.getAsStream(binaryDataId); } catch (error) { if (error instanceof FileNotFoundError) return res.writeHead(404).end(); else throw error; diff --git a/packages/cli/test/integration/binaryData.api.test.ts b/packages/cli/test/integration/binaryData.api.test.ts index 9492849fcec2f..fbb47f2f1299a 100644 --- a/packages/cli/test/integration/binaryData.api.test.ts +++ b/packages/cli/test/integration/binaryData.api.test.ts @@ -1,17 +1,10 @@ -import fs from 'node:fs'; import fsp from 'node:fs/promises'; +import { Readable } from 'node:stream'; import { BinaryDataService, FileNotFoundError } from 'n8n-core'; import * as testDb from './shared/testDb'; import { mockInstance, setupTestServer } from './shared/utils'; import type { SuperAgentTest } from 'supertest'; -jest.mock('fs', () => { - return { - ...jest.requireActual('fs'), - createReadStream: jest.fn(), - }; -}); - jest.mock('fs/promises'); const throwFileNotFound = () => { @@ -39,7 +32,9 @@ describe('GET /binary-data', () => { const mimeType = 'text/plain'; const fileName = 'test.txt'; const buffer = Buffer.from('content'); - const bufferResponse = '{"data":{"type":"Buffer","data":[99,111,110,116,101,110,116]}}'; + const mockStream = new Readable(); + mockStream.push(buffer); + mockStream.push(null); describe('should reject on missing or invalid binary data ID', () => { test.each([['view'], ['download']])('on request to %s', async (action) => { @@ -69,8 +64,7 @@ describe('GET /binary-data', () => { describe('should return binary data [filesystem]', () => { test.each([['view'], ['download']])('on request to %s', async (action) => { - binaryDataService.getPath.mockReturnValue(binaryFilePath); - fs.createReadStream = jest.fn().mockReturnValue(buffer); + binaryDataService.getAsStream.mockResolvedValue(mockStream); const res = await authOwnerAgent .get('/binary-data') @@ -85,16 +79,15 @@ describe('GET /binary-data', () => { const contentDisposition = action === 'download' ? `attachment; filename="${fileName}"` : undefined; - expect(res.text).toBe(bufferResponse); - expect(res.headers['content-type']).toBe(mimeType + '; charset=utf-8'); - expect(res.headers['content-length']).toBe(bufferResponse.length.toString()); + expect(binaryDataService.getAsStream).toHaveBeenCalledWith(fsBinaryDataId); + expect(res.headers['content-type']).toBe(mimeType); expect(res.headers['content-disposition']).toBe(contentDisposition); }); }); describe('should return 404 on file not found [filesystem]', () => { test.each(['view', 'download'])('on request to %s', async (action) => { - binaryDataService.getPath.mockImplementation(throwFileNotFound); + binaryDataService.getAsStream.mockImplementation(throwFileNotFound); await authOwnerAgent .get('/binary-data') @@ -110,7 +103,7 @@ describe('GET /binary-data', () => { describe('should return binary data [s3]', () => { test.each([['view'], ['download']])('on request to %s', async (action) => { - binaryDataService.getPath.mockReturnValue(binaryFilePath); + binaryDataService.getAsStream.mockResolvedValue(mockStream); const res = await authOwnerAgent .get('/binary-data') @@ -127,14 +120,14 @@ describe('GET /binary-data', () => { const contentDisposition = action === 'download' ? `attachment; filename="${fileName}"` : undefined; - expect(res.headers['content-type']).toBe(mimeType + '; charset=utf-8'); + expect(res.headers['content-type']).toBe(mimeType); expect(res.headers['content-disposition']).toBe(contentDisposition); }); }); describe('should return 404 on file not found [s3]', () => { test.each(['view', 'download'])('on request to %s', async (action) => { - binaryDataService.getPath.mockImplementation(throwFileNotFound); + binaryDataService.getAsStream.mockImplementation(throwFileNotFound); await authOwnerAgent .get('/binary-data') From 181036e7b6a8fbd1c1a7150fe41184c46b058cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 15:48:39 +0200 Subject: [PATCH 7/8] Remove `binaryDataService` from `Server` --- packages/cli/src/Server.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 3960ea15f2597..f5cdd87cf65d7 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -25,7 +25,6 @@ import axios from 'axios'; import type { RequestOptions } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; -import type { BinaryDataService } from 'n8n-core'; import { Credentials, LoadMappingOptions, @@ -207,8 +206,6 @@ export class Server extends AbstractServer { push: Push; - binaryDataService: BinaryDataService; - constructor() { super('main'); From 3a3f7431de005dc89da600ffe478ef33a4cb2c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 6 Oct 2023 15:49:52 +0200 Subject: [PATCH 8/8] Tighten check --- packages/cli/src/ResponseHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 48a0cccc93ca8..3a3510e83d853 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -6,7 +6,7 @@ import type { Request, Response } from 'express'; import { parse, stringify } from 'flatted'; import picocolors from 'picocolors'; import { ErrorReporterProxy as ErrorReporter, NodeApiError } from 'n8n-workflow'; -import { Stream } from 'node:stream'; +import { Readable } from 'node:stream'; import type { IExecutionDb, IExecutionFlatted, @@ -101,7 +101,7 @@ export function sendSuccessResponse( res.header(responseHeader); } - if (data instanceof Stream) { + if (data instanceof Readable) { data.pipe(res); return; }