Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,132 changes: 2,552 additions & 4,580 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/cli/src/import/importAssetsFromCsv.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import globby from 'globby';
import upath from 'upath';
import createSpinner from 'ora';
import type { Alpha } from '@lifeomic/alpha';
import type { ApiClient } from '@jupiterone/integration-sdk-runtime';
import path from 'path';
import pMap from 'p-map';
import { retry } from '@lifeomic/attempt';
Expand Down Expand Up @@ -33,7 +33,7 @@ async function waitForSyncCompletion({ jobId, apiClient, progress }) {

interface ImportAssetsTypeParams {
storageDirectory: string;
apiClient: Alpha;
apiClient: ApiClient;
jobId: string;
assetType: 'entities' | 'relationships';
progress: (currentFile: string) => void;
Expand Down
51 changes: 50 additions & 1 deletion packages/integration-sdk-runtime/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @jupiterone/integration-sdk-core
# @jupiterone/integration-sdk-runtime

_NOTE:_ This project is currently under development and the API interface is not
stable. Use at your own risk.
Expand All @@ -14,3 +14,52 @@ npm install @jupiterone/integration-sdk-runtime

yarn add @jupiterone/integration-sdk-runtime
```

## HTTP Client

The SDK runtime now uses a lightweight HTTP client built on top of `undici`
instead of `@lifeomic/alpha`. This provides:

- **Lightweight**: Minimal dependencies, built on Node.js native `undici`
- **Proxy Support**: Full HTTP/HTTPS proxy support via the `proxy` option
- **Node 22 Ready**: Compatible with Node.js 18+ including Node 22
- **Retry Logic**: Built-in exponential backoff retry mechanism
- **Interceptor Support**: Compatible with existing interceptor patterns

### Usage with Proxy

```typescript
import { createApiClient } from '@jupiterone/integration-sdk-runtime';

const client = createApiClient({
apiBaseUrl: 'https://api.us.jupiterone.io',
account: 'your-account',
accessToken: 'your-token',
proxy: 'http://proxy.example.com:8080', // HTTP proxy
// or
proxy: 'https://proxy.example.com:8443', // HTTPS proxy
timeout: 30000, // 30 seconds
retryOptions: {
attempts: 3,
factor: 2,
maxTimeout: 30000,
},
});
```

### Environment Variables

You can also configure the proxy via environment variables:

```bash
export HTTP_PROXY=http://proxy.example.com:8080
export HTTPS_PROXY=https://proxy.example.com:8443
```

## Features

- **Synchronization**: Upload collected data to JupiterOne
- **Execution**: Run integration steps with dependency management
- **Storage**: File system and in-memory storage options
- **Logging**: Structured logging with Bunyan
- **Metrics**: Performance monitoring and reporting
4 changes: 2 additions & 2 deletions packages/integration-sdk-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"dist"
],
"engines": {
"node": ">=18.0.0 <21.x"
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public"
Expand All @@ -24,8 +24,8 @@
},
"dependencies": {
"@jupiterone/integration-sdk-core": "^17.0.2",
"@lifeomic/alpha": "^5.2.0",
"@lifeomic/attempt": "^3.0.3",
"undici": "^6.0.0",
"async-sema": "^3.1.0",
"bunyan": "^1.8.12",
"bunyan-format": "^0.2.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mocked } from 'jest-mock';
import { Alpha } from '@lifeomic/alpha';
import { HttpClient } from '../http-client';

import {
getApiBaseUrl,
Expand All @@ -8,11 +8,10 @@ import {
getAccountFromEnvironment,
compressRequest,
} from '../index';
import { AxiosRequestConfig } from 'axios';

jest.mock('@lifeomic/alpha');
jest.mock('../http-client');

const AlphaMock = mocked(Alpha);
const HttpClientMock = mocked(HttpClient);

describe('getApiBaseUrl', () => {
test('returns development base url if dev option is set to true', () => {
Expand Down Expand Up @@ -91,10 +90,10 @@ describe('createApiClient', () => {
},
});

expect(client).toBeInstanceOf(AlphaMock);
expect(client).toBeInstanceOf(HttpClientMock);

expect(AlphaMock).toHaveReturnedTimes(1);
expect(AlphaMock).toHaveBeenCalledWith({
expect(HttpClientMock).toHaveReturnedTimes(1);
expect(HttpClientMock).toHaveBeenCalledWith({
baseURL: apiBaseUrl,
headers: {
Authorization: 'Bearer test-key',
Expand All @@ -110,7 +109,7 @@ describe('createApiClient', () => {

describe('compressRequest', () => {
it('should compress the request data when the URL matches', async () => {
const config: AxiosRequestConfig = {
const config: any = {
method: 'post',
url: '/persister/synchronization/jobs/478d5718-69a7-4204-90b7-7d9f01de374f/entities',
headers: {},
Expand Down Expand Up @@ -147,7 +146,7 @@ describe('compressRequest', () => {
describe('real Alpha request with fake API key', () => {
test('should not expose API key in error', async () => {
jest.resetModules();
jest.unmock('@lifeomic/alpha');
jest.unmock('../http-client');

const { createApiClient, getApiBaseUrl } = require('../index');

Expand Down
233 changes: 233 additions & 0 deletions packages/integration-sdk-runtime/src/api/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { request } from 'undici';
import { URL } from 'url';

export interface HttpClientOptions {
baseURL: string;
headers?: Record<string, string>;
timeout?: number;
proxy?: string;
retry?: {
attempts?: number;
factor?: number;
maxTimeout?: number;
retryCondition?: (err: Error) => boolean;
};
}

export interface HttpClientResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
}

export interface HttpClientRequestConfig {
headers?: Record<string, string>;
timeout?: number;
}

export class HttpClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
private timeout: number;
private retryOptions: HttpClientOptions['retry'];

constructor(options: HttpClientOptions) {
this.baseURL = options.baseURL;
this.defaultHeaders = options.headers || {};
this.timeout = options.timeout || 30000;
this.retryOptions = options.retry || {};
}

private async makeRequest<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
url: string,
data?: any,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
const fullUrl = new URL(url, this.baseURL).toString();
const headers = { ...this.defaultHeaders, ...config?.headers };

if (data && typeof data === 'object' && !Buffer.isBuffer(data)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}

const requestOptions = {
method,
headers,
body: data
? Buffer.isBuffer(data)
? data
: JSON.stringify(data)
: undefined,
signal: AbortSignal.timeout(config?.timeout || this.timeout),
};

const response = await request(fullUrl, requestOptions);

let responseData: T;
const contentType = response.headers['content-type'] || '';

if (contentType.includes('application/json')) {
responseData = (await response.body.json()) as T;
} else {
responseData = (await response.body.text()) as T;
}

return {
data: responseData,
status: response.statusCode,
statusText: response.statusCode.toString(),
headers: response.headers as Record<string, string>,
};
}

private async retryRequest<T = any>(
requestFn: () => Promise<HttpClientResponse<T>>,
): Promise<HttpClientResponse<T>> {
const {
attempts = 3,
factor = 2,
maxTimeout = 30000,
retryCondition,
} = this.retryOptions || {};

let lastError: Error;

for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await requestFn();
} catch (error) {
lastError = error as Error;

// Check if we should retry
if (retryCondition && !retryCondition(lastError)) {
throw lastError;
}

// Don't retry on last attempt
if (attempt === attempts) {
throw lastError;
}

// Calculate delay with exponential backoff
const delay = Math.min(
Math.pow(factor, attempt - 1) * 1000,
maxTimeout,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}

throw lastError!;
}

async get<T = any>(
url: string,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
return this.retryRequest(() =>
this.applyInterceptors<T>('GET', url, undefined, config),
);
}

async post<T = any>(
url: string,
data?: any,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
return this.retryRequest(() =>
this.applyInterceptors<T>('POST', url, data, config),
);
}

async put<T = any>(
url: string,
data?: any,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
return this.retryRequest(() =>
this.applyInterceptors<T>('PUT', url, data, config),
);
}

async delete<T = any>(
url: string,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
return this.retryRequest(() =>
this.applyInterceptors<T>('DELETE', url, undefined, config),
);
}

async patch<T = any>(
url: string,
data?: any,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
return this.retryRequest(() =>
this.applyInterceptors<T>('PATCH', url, data, config),
);
}

// Interceptor support for compatibility
interceptors = {
request: {
use: (handler: (config: any) => any) => {
// Store request interceptor for future use
this.requestInterceptor = handler;
},
},
response: {
use: (
onFulfilled?: (response: any) => any,
onRejected?: (error: any) => any,
) => {
// Store response interceptors for future use
this.responseFulfilledInterceptor = onFulfilled;
this.responseRejectedInterceptor = onRejected;
},
},
};

private requestInterceptor?: (config: any) => any;
private responseFulfilledInterceptor?: (response: any) => any;
private responseRejectedInterceptor?: (error: any) => any;

// Method to apply interceptors (to be called before making requests)
private async applyInterceptors<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
url: string,
data?: any,
config?: HttpClientRequestConfig,
): Promise<HttpClientResponse<T>> {
let requestConfig = { method, url, data, ...config };

// Apply request interceptor
if (this.requestInterceptor) {
requestConfig = await this.requestInterceptor(requestConfig);
}

try {
const response = await this.makeRequest<T>(
requestConfig.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
requestConfig.url,
requestConfig.data,
requestConfig,
);

// Apply response fulfilled interceptor
if (this.responseFulfilledInterceptor) {
return await this.responseFulfilledInterceptor(response);
}

return response;
} catch (error) {
// Apply response rejected interceptor
if (this.responseRejectedInterceptor) {
throw await this.responseRejectedInterceptor(error);
}
throw error;
}
}
}
Loading
Loading