refactor(editor): Extract API Requests into @n8n/rest-api-client package (no-changelog) (#15930)

This commit is contained in:
Alex Grozav
2025-06-05 12:08:10 +02:00
committed by GitHub
parent a18822af0e
commit 6cf07200dc
90 changed files with 502 additions and 279 deletions

View File

@@ -0,0 +1,10 @@
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n/eslint-config/frontend'],
...sharedOptions(__dirname, 'frontend'),
};

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,22 @@
# @n8n/rest-api-client
This package contains the REST API calls for n8n.
## Table of Contents
- [Features](#features)
- [Contributing](#contributing)
- [License](#license)
## Features
- Provides a REST API for n8n
- Supports authentication and authorization
## Contributing
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
## License
For more details, please read our [LICENSE.md](LICENSE.md).

View File

@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}

View File

@@ -0,0 +1,58 @@
{
"name": "@n8n/rest-api-client",
"type": "module",
"version": "1.0.0",
"files": [
"dist"
],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"require": "./dist/*.cjs"
}
},
"scripts": {
"dev": "vite",
"build": "pnpm run typecheck && tsup",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --ext .js,.ts,.vue --quiet",
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/utils": "workspace:*",
"js-base64": "catalog:",
"n8n-workflow": "workspace:*",
"axios": "catalog:",
"flatted": "catalog:"
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/i18n": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"tsup": "catalog:",
"typescript": "catalog:frontend",
"vite": "catalog:frontend",
"vitest": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,40 @@
import type {
CreateApiKeyRequestDto,
UpdateApiKeyRequestDto,
ApiKey,
ApiKeyWithRawValue,
} from '@n8n/api-types';
import type { ApiKeyScope } from '@n8n/permissions';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys');
}
export async function getApiKeyScopes(context: IRestApiContext): Promise<ApiKeyScope[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys/scopes');
}
export async function createApiKey(
context: IRestApiContext,
payload: CreateApiKeyRequestDto,
): Promise<ApiKeyWithRawValue> {
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
}
export async function deleteApiKey(
context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`);
}
export async function updateApiKey(
context: IRestApiContext,
id: string,
payload: UpdateApiKeyRequestDto,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload);
}

View File

@@ -0,0 +1,31 @@
import type { PublicInstalledPackage } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { get, post, makeRestApiRequest } from '../utils';
export async function getInstalledCommunityNodes(
context: IRestApiContext,
): Promise<PublicInstalledPackage[]> {
const response = await get(context.baseUrl, '/community-packages');
return response.data || [];
}
export async function installNewPackage(
context: IRestApiContext,
name: string,
verify?: boolean,
version?: string,
): Promise<PublicInstalledPackage> {
return await post(context.baseUrl, '/community-packages', { name, verify, version });
}
export async function uninstallPackage(context: IRestApiContext, name: string): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', '/community-packages', { name });
}
export async function updatePackage(
context: IRestApiContext,
name: string,
): Promise<PublicInstalledPackage> {
return await makeRestApiRequest(context, 'PATCH', '/community-packages', { name });
}

View File

@@ -0,0 +1,8 @@
import type { IRestApiContext } from '../types';
import { get } from '../utils';
export async function getBecomeCreatorCta(context: IRestApiContext): Promise<boolean> {
const response = await get(context.baseUrl, '/cta/become-creator');
return response;
}

View File

@@ -0,0 +1,50 @@
import type { IDataObject, MessageEventBusDestinationOptions } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export type ApiMessageEventBusDestinationOptions = MessageEventBusDestinationOptions & {
id: string;
};
export function hasDestinationId(
destination: MessageEventBusDestinationOptions,
): destination is ApiMessageEventBusDestinationOptions {
return destination.id !== undefined;
}
export async function saveDestinationToDb(
context: IRestApiContext,
destination: ApiMessageEventBusDestinationOptions,
subscribedEvents: string[] = [],
) {
const data: IDataObject = {
...destination,
subscribedEvents,
};
return await makeRestApiRequest(context, 'POST', '/eventbus/destination', data);
}
export async function deleteDestinationFromDb(context: IRestApiContext, destinationId: string) {
return await makeRestApiRequest(context, 'DELETE', `/eventbus/destination?id=${destinationId}`);
}
export async function sendTestMessageToDestination(
context: IRestApiContext,
destination: ApiMessageEventBusDestinationOptions,
): Promise<boolean> {
const data: IDataObject = {
...destination,
};
return await makeRestApiRequest(context, 'GET', '/eventbus/testmessage', data);
}
export async function getEventNamesFromBackend(context: IRestApiContext): Promise<string[]> {
return await makeRestApiRequest(context, 'GET', '/eventbus/eventnames');
}
export async function getDestinationsFromBackend(
context: IRestApiContext,
): Promise<MessageEventBusDestinationOptions[]> {
return await makeRestApiRequest(context, 'GET', '/eventbus/destination');
}

View File

@@ -0,0 +1,6 @@
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function sessionStarted(context: IRestApiContext): Promise<void> {
return await makeRestApiRequest(context, 'GET', '/events/session-started');
}

View File

@@ -0,0 +1,12 @@
export * from './api-keys';
export * from './communityNodes';
export * from './ctas';
export * from './eventbus.ee';
export * from './events';
export * from './mfa';
export * from './nodeTypes';
export * from './npsSurvey';
export * from './orchestration';
export * from './roles';
export * from './ui';
export * from './webhooks';

View File

@@ -0,0 +1,35 @@
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function canEnableMFA(context: IRestApiContext) {
return await makeRestApiRequest(context, 'POST', '/mfa/can-enable');
}
export async function getMfaQR(
context: IRestApiContext,
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
return await makeRestApiRequest(context, 'GET', '/mfa/qr');
}
export async function enableMfa(
context: IRestApiContext,
data: { mfaCode: string },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
}
export async function verifyMfaCode(
context: IRestApiContext,
data: { mfaCode: string },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
}
export type DisableMfaParams = {
mfaCode?: string;
mfaRecoveryCode?: string;
};
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/disable', data);
}

View File

@@ -0,0 +1,124 @@
import type {
ActionResultRequestDto,
CommunityNodeType,
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
} from '@n8n/api-types';
import type { INodeTranslationHeaders } from '@n8n/i18n';
import axios from 'axios';
import type {
INodeListSearchResult,
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
NodeParameterValueType,
ResourceMapperFields,
} from 'n8n-workflow';
import { sleep } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) {
for (let attempt = 0; attempt < retries; attempt++) {
const response = await axios.get(url, { withCredentials: true });
if (typeof response.data === 'object' && response.data !== null) {
return response.data;
}
await sleep(delay * attempt);
}
throw new Error('Could not fetch node types');
}
export async function getNodeTypes(baseUrl: string) {
return await fetchNodeTypesJsonWithRetry(baseUrl + 'types/nodes.json');
}
export async function fetchCommunityNodeTypes(
context: IRestApiContext,
): Promise<CommunityNodeType[]> {
return await makeRestApiRequest(context, 'GET', '/community-node-types');
}
export async function fetchCommunityNodeAttributes(
context: IRestApiContext,
type: string,
): Promise<CommunityNodeType | null> {
return await makeRestApiRequest(
context,
'GET',
`/community-node-types/${encodeURIComponent(type)}`,
);
}
export async function getNodeTranslationHeaders(
context: IRestApiContext,
): Promise<INodeTranslationHeaders | undefined> {
return await makeRestApiRequest(context, 'GET', '/node-translation-headers');
}
export async function getNodesInformation(
context: IRestApiContext,
nodeInfos: INodeTypeNameVersion[],
): Promise<INodeTypeDescription[]> {
return await makeRestApiRequest(context, 'POST', '/node-types', { nodeInfos });
}
export async function getNodeParameterOptions(
context: IRestApiContext,
sendData: OptionsRequestDto,
): Promise<INodePropertyOptions[]> {
return await makeRestApiRequest(context, 'POST', '/dynamic-node-parameters/options', sendData);
}
export async function getResourceLocatorResults(
context: IRestApiContext,
sendData: ResourceLocatorRequestDto,
): Promise<INodeListSearchResult> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/resource-locator-results',
sendData,
);
}
export async function getResourceMapperFields(
context: IRestApiContext,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/resource-mapper-fields',
sendData,
);
}
export async function getLocalResourceMapperFields(
context: IRestApiContext,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/local-resource-mapper-fields',
sendData,
);
}
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: ActionResultRequestDto,
): Promise<NodeParameterValueType> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/action-result',
sendData,
);
}

View File

@@ -0,0 +1,8 @@
import type { NpsSurveyState } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function updateNpsSurveyState(context: IRestApiContext, state: NpsSurveyState) {
await makeRestApiRequest(context, 'PATCH', '/user-settings/nps-survey', state);
}

View File

@@ -0,0 +1,8 @@
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
const GET_STATUS_ENDPOINT = '/orchestration/worker/status';
export const sendGetWorkerStatus = async (context: IRestApiContext): Promise<void> => {
await makeRestApiRequest(context, 'POST', GET_STATUS_ENDPOINT);
};

View File

@@ -0,0 +1,8 @@
import type { AllRolesMap } from '@n8n/permissions';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export const getRoles = async (context: IRestApiContext): Promise<AllRolesMap> => {
return await makeRestApiRequest(context, 'GET', '/roles');
};

View File

@@ -0,0 +1,13 @@
import type { BannerName } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function dismissBannerPermanently(
context: IRestApiContext,
data: { bannerName: BannerName; dismissedBanners: string[] },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/owner/dismiss-banner', {
banner: data.bannerName,
});
}

View File

@@ -0,0 +1,18 @@
import type { IHttpRequestMethods } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
type WebhookData = {
workflowId: string;
webhookPath: string;
method: IHttpRequestMethods;
node: string;
};
export const findWebhook = async (
context: IRestApiContext,
data: { path: string; method: string },
): Promise<WebhookData | null> => {
return await makeRestApiRequest(context, 'POST', '/webhooks/find', data);
};

View File

@@ -0,0 +1,3 @@
export * from './api';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,4 @@
export interface IRestApiContext {
baseUrl: string;
pushRef: string;
}

View File

@@ -0,0 +1,160 @@
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './utils';
describe('streamRequest', () => {
it('should stream data from the API endpoint', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPERATOR}`));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).toHaveBeenCalledTimes(3);
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
expect(onDoneMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).not.toHaveBeenCalled();
});
it('should stream error response from the API endpoint', async () => {
const testError = { code: 500, message: 'Error happened' };
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(JSON.stringify(testError)));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).not.toHaveBeenCalled();
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(new ResponseError(testError.message));
});
it('should handle broken stream data', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}{"chunk": `),
);
controller.enqueue(encoder.encode(`2}${STREAM_SEPERATOR}{"ch`));
controller.enqueue(encoder.encode('unk":'));
controller.enqueue(encoder.encode(`3}${STREAM_SEPERATOR}`));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).toHaveBeenCalledTimes(3);
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
expect(onDoneMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,289 @@
import { BROWSER_ID_STORAGE_KEY } from '@n8n/constants';
import { assert } from '@n8n/utils/assert';
import type { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import type { GenericValue, IDataObject } from 'n8n-workflow';
import type { IRestApiContext } from './types';
const getBrowserId = () => {
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
if (!browserId) {
browserId = crypto.randomUUID();
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
}
return browserId;
};
export const NO_NETWORK_ERROR_CODE = 999;
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
export class ResponseError extends ApplicationError {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the response
errorCode?: number;
// The stack trace of the server
serverStackTrace?: string;
/**
* Creates an instance of ResponseError.
* @param {string} message The error message
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
* @param {number} [httpStatusCode] The HTTP status code the response should have
* @param {string} [stack] The stack trace
*/
constructor(
message: string,
options: { errorCode?: number; httpStatusCode?: number; stack?: string } = {},
) {
super(message);
this.name = 'ResponseError';
const { errorCode, httpStatusCode, stack } = options;
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
if (stack) {
this.serverStackTrace = stack;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacyParamSerializer = (params: Record<string, any>) =>
Object.keys(params)
.filter((key) => params[key] !== undefined)
.map((key) => {
if (Array.isArray(params[key])) {
return params[key].map((v: string) => `${key}[]=${encodeURIComponent(v)}`).join('&');
}
if (typeof params[key] === 'object') {
params[key] = JSON.stringify(params[key]);
}
return `${key}=${encodeURIComponent(params[key])}`;
})
.join('&');
export async function request(config: {
method: Method;
baseURL: string;
endpoint: string;
headers?: RawAxiosRequestHeaders;
data?: GenericValue | GenericValue[];
withCredentials?: boolean;
}) {
const { method, baseURL, endpoint, headers, data } = config;
const options: AxiosRequestConfig = {
method,
url: endpoint,
baseURL,
headers: headers ?? {},
};
if (baseURL.startsWith('/')) {
options.headers!['browser-id'] = getBrowserId();
}
if (
import.meta.env.NODE_ENV !== 'production' &&
!baseURL.includes('api.n8n.io') &&
!baseURL.includes('n8n.cloud')
) {
options.withCredentials = options.withCredentials ?? true;
}
if (['POST', 'PATCH', 'PUT'].includes(method)) {
options.data = data;
} else if (data) {
options.params = data;
options.paramsSerializer = legacyParamSerializer;
}
try {
const response = await axios.request(options);
return response.data;
} catch (error) {
if (error.message === 'Network Error') {
throw new ResponseError("Can't connect to n8n.", {
errorCode: NO_NETWORK_ERROR_CODE,
});
}
const errorResponseData = error.response?.data;
if (errorResponseData?.message !== undefined) {
if (errorResponseData.name === 'NodeApiError') {
errorResponseData.httpStatusCode = error.response.status;
throw errorResponseData;
}
throw new ResponseError(errorResponseData.message, {
errorCode: errorResponseData.code,
httpStatusCode: error.response.status,
stack: errorResponseData.stack,
});
}
throw error;
}
}
/**
* Sends a request to the API and returns the response without extracting the data key.
* @param context Rest API context
* @param method HTTP method
* @param endpoint relative path to the API endpoint
* @param data request data
* @returns data and total count
*/
export async function getFullApiResponse<T>(
context: IRestApiContext,
method: Method,
endpoint: string,
data?: GenericValue | GenericValue[],
) {
const response = await request({
method,
baseURL: context.baseUrl,
endpoint,
headers: { 'push-ref': context.pushRef },
data,
});
return response as { count: number; data: T };
}
export async function makeRestApiRequest<T>(
context: IRestApiContext,
method: Method,
endpoint: string,
data?: GenericValue | GenericValue[],
) {
const response = await request({
method,
baseURL: context.baseUrl,
endpoint,
headers: { 'push-ref': context.pushRef },
data,
});
// All cli rest api endpoints return data wrapped in `data` key
return response.data as T;
}
export async function get(
baseURL: string,
endpoint: string,
params?: IDataObject,
headers?: RawAxiosRequestHeaders,
) {
return await request({ method: 'GET', baseURL, endpoint, headers, data: params });
}
export async function post(
baseURL: string,
endpoint: string,
params?: IDataObject,
headers?: RawAxiosRequestHeaders,
) {
return await request({ method: 'POST', baseURL, endpoint, headers, data: params });
}
export async function patch(
baseURL: string,
endpoint: string,
params?: IDataObject,
headers?: RawAxiosRequestHeaders,
) {
return await request({ method: 'PATCH', baseURL, endpoint, headers, data: params });
}
export async function streamRequest<T extends object>(
context: IRestApiContext,
apiEndpoint: string,
payload: object,
onChunk?: (chunk: T) => void,
onDone?: () => void,
onError?: (e: Error) => void,
separator = STREAM_SEPERATOR,
): Promise<void> {
const headers: Record<string, string> = {
'browser-id': getBrowserId(),
'Content-Type': 'application/json',
};
const assistantRequest: RequestInit = {
headers,
method: 'POST',
credentials: 'include',
body: JSON.stringify(payload),
};
try {
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
if (response.body) {
// Handle the streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
async function readStream() {
const { done, value } = await reader.read();
if (done) {
onDone?.();
return;
}
const chunk = decoder.decode(value);
buffer += chunk;
const splitChunks = buffer.split(separator);
buffer = '';
for (const splitChunk of splitChunks) {
if (splitChunk) {
let data: T;
try {
data = jsonParse<T>(splitChunk, { errorMessage: 'Invalid json' });
} catch (e) {
// incomplete json. append to buffer to complete
buffer += splitChunk;
continue;
}
try {
if (response.ok) {
// Call chunk callback if request was successful
onChunk?.(data);
} else {
// Otherwise, call error callback
const message = 'message' in data ? data.message : response.statusText;
onError?.(
new ResponseError(String(message), {
httpStatusCode: response.status,
}),
);
}
} catch (e: unknown) {
if (e instanceof Error) {
onError?.(e);
}
}
}
}
await readStream();
}
// Start reading the stream
await readStream();
} else if (onError) {
onError(new Error(response.statusText));
}
} catch (e: unknown) {
assert(e instanceof Error);
onError?.(e as Error);
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@n8n/typescript-config/tsconfig.frontend.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"useUnknownInCatchVariables": false,
"types": ["vite/client", "vitest/globals"],
"isolatedModules": true,
"paths": {
"@n8n/utils/*": ["../../../@n8n/utils/src/*"]
}
},
"include": ["src/**/*.ts", "vite.config.ts", "tsup.config.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'],
format: ['cjs', 'esm'],
clean: true,
dts: true,
cjsInterop: true,
splitting: true,
sourcemap: true,
});

View File

@@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { createVitestConfig } from '@n8n/vitest-config/frontend';
export default mergeConfig(defineConfig({}), createVitestConfig({ setupFiles: [] }));