BREAKING CHANGE(core): major architectural refactoring with fetch-like API
Some checks failed
Default (tags) / security (push) Failing after 24s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped

This commit is contained in:
2025-07-27 21:23:20 +00:00
parent f7d2c6de4f
commit bbb57004d9
24 changed files with 1038 additions and 593 deletions

4
ts/core/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Core exports
export * from './types.js';
export * from './response.js';
export { request, coreRequest, isUnixSocket, parseUnixSocketUrl } from './request.js';

View File

@@ -16,4 +16,4 @@ export { smartpromise, smarturl };
import agentkeepalive from 'agentkeepalive';
import formData from 'form-data';
export { agentkeepalive, formData };
export { agentkeepalive, formData };

159
ts/core/request.ts Normal file
View File

@@ -0,0 +1,159 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
import { SmartResponse } from './response.js';
// Keep-alive agents for connection pooling
const httpAgent = new plugins.agentkeepalive({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpAgentKeepAliveFalse = new plugins.agentkeepalive({
keepAlive: false,
});
const httpsAgent = new plugins.agentkeepalive.HttpsAgent({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpsAgentKeepAliveFalse = new plugins.agentkeepalive.HttpsAgent({
keepAlive: false,
});
/**
* Tests if a URL is a unix socket
*/
export const isUnixSocket = (url: string): boolean => {
const unixRegex = /^(http:\/\/|https:\/\/|)unix:/;
return unixRegex.test(url);
};
/**
* Parses socket path and route from unix socket URL
*/
export const parseUnixSocketUrl = (url: string): { socketPath: string; path: string } => {
const parseRegex = /(.*):(.*)/;
const result = parseRegex.exec(url);
return {
socketPath: result[1],
path: result[2],
};
};
/**
* Core request function that handles all HTTP/HTTPS requests
*/
export async function coreRequest(
urlArg: string,
optionsArg: types.ICoreRequestOptions = {},
requestDataFunc: ((req: plugins.http.ClientRequest) => void) | null = null
): Promise<plugins.http.IncomingMessage> {
const done = plugins.smartpromise.defer<plugins.http.IncomingMessage>();
// No defaults - let users explicitly set options to match fetch behavior
// Parse URL
const parsedUrl = plugins.smarturl.Smarturl.createFromUrl(urlArg, {
searchParams: optionsArg.queryParams || {},
});
optionsArg.hostname = parsedUrl.hostname;
if (parsedUrl.port) {
optionsArg.port = parseInt(parsedUrl.port, 10);
}
optionsArg.path = parsedUrl.path;
// Handle unix socket URLs
if (isUnixSocket(urlArg)) {
const { socketPath, path } = parseUnixSocketUrl(optionsArg.path);
optionsArg.socketPath = socketPath;
optionsArg.path = path;
}
// Determine agent based on protocol and keep-alive setting
if (!optionsArg.agent) {
// Only use keep-alive agents if explicitly requested
if (optionsArg.keepAlive === true) {
optionsArg.agent = parsedUrl.protocol === 'https:' ? httpsAgent : httpAgent;
} else if (optionsArg.keepAlive === false) {
optionsArg.agent = parsedUrl.protocol === 'https:' ? httpsAgentKeepAliveFalse : httpAgentKeepAliveFalse;
}
// If keepAlive is undefined, don't set any agent (more fetch-like behavior)
}
// Determine request module
const requestModule = parsedUrl.protocol === 'https:' ? plugins.https : plugins.http;
if (!requestModule) {
throw new Error(`The request to ${urlArg} is missing a viable protocol. Must be http or https`);
}
// Perform the request
const request = requestModule.request(optionsArg, async (response) => {
// Handle hard timeout
if (optionsArg.hardDataCuttingTimeout) {
setTimeout(() => {
response.destroy();
done.reject(new Error('Request timed out'));
}, optionsArg.hardDataCuttingTimeout);
}
// Always return the raw stream
done.resolve(response);
});
// Write request body
if (optionsArg.requestBody) {
if (optionsArg.requestBody instanceof plugins.formData) {
optionsArg.requestBody.pipe(request).on('finish', () => {
request.end();
});
} else {
// Write body as-is - caller is responsible for serialization
const bodyData = typeof optionsArg.requestBody === 'string'
? optionsArg.requestBody
: optionsArg.requestBody instanceof Buffer
? optionsArg.requestBody
: JSON.stringify(optionsArg.requestBody); // Still stringify for backward compatibility
request.write(bodyData);
request.end();
}
} else if (requestDataFunc) {
requestDataFunc(request);
} else {
request.end();
}
// Handle request errors
request.on('error', (e) => {
console.error(e);
request.destroy();
done.reject(e);
});
// Get response and handle response errors
const response = await done.promise;
response.on('error', (err) => {
console.error(err);
response.destroy();
});
return response;
}
/**
* Modern request function that returns a SmartResponse
*/
export async function request(
urlArg: string,
optionsArg: types.ICoreRequestOptions = {}
): Promise<SmartResponse> {
const response = await coreRequest(urlArg, optionsArg);
return new SmartResponse(response, urlArg);
}

110
ts/core/response.ts Normal file
View File

@@ -0,0 +1,110 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
/**
* Modern Response class that provides a fetch-like API
*/
export class SmartResponse<T = any> implements types.ICoreResponse<T> {
private incomingMessage: plugins.http.IncomingMessage;
private bodyBufferPromise: Promise<Buffer> | null = null;
private consumed = false;
// Public properties
public readonly ok: boolean;
public readonly status: number;
public readonly statusText: string;
public readonly headers: plugins.http.IncomingHttpHeaders;
public readonly url: string;
constructor(incomingMessage: plugins.http.IncomingMessage, url: string) {
this.incomingMessage = incomingMessage;
this.url = url;
this.status = incomingMessage.statusCode || 0;
this.statusText = incomingMessage.statusMessage || '';
this.ok = this.status >= 200 && this.status < 300;
this.headers = incomingMessage.headers;
}
/**
* Ensures the body can only be consumed once
*/
private ensureNotConsumed(): void {
if (this.consumed) {
throw new Error('Body has already been consumed');
}
this.consumed = true;
}
/**
* Collects the body as a buffer
*/
private async collectBody(): Promise<Buffer> {
this.ensureNotConsumed();
if (this.bodyBufferPromise) {
return this.bodyBufferPromise;
}
this.bodyBufferPromise = new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
this.incomingMessage.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
this.incomingMessage.on('end', () => {
resolve(Buffer.concat(chunks));
});
this.incomingMessage.on('error', reject);
});
return this.bodyBufferPromise;
}
/**
* Parse response as JSON
*/
async json(): Promise<T> {
const buffer = await this.collectBody();
const text = buffer.toString('utf-8');
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
}
}
/**
* Get response as text
*/
async text(): Promise<string> {
const buffer = await this.collectBody();
return buffer.toString('utf-8');
}
/**
* Get response as ArrayBuffer
*/
async arrayBuffer(): Promise<ArrayBuffer> {
const buffer = await this.collectBody();
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Get response as a readable stream
*/
stream(): NodeJS.ReadableStream {
this.ensureNotConsumed();
return this.incomingMessage;
}
/**
* Get the raw IncomingMessage (for legacy compatibility)
*/
raw(): plugins.http.IncomingMessage {
return this.incomingMessage;
}
}

67
ts/core/types.ts Normal file
View File

@@ -0,0 +1,67 @@
import * as plugins from './plugins.js';
/**
* Core request options extending Node.js RequestOptions
*/
export interface ICoreRequestOptions extends plugins.https.RequestOptions {
keepAlive?: boolean;
requestBody?: any;
queryParams?: { [key: string]: string };
hardDataCuttingTimeout?: number;
}
/**
* HTTP Methods supported
*/
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
/**
* Response types supported
*/
export type ResponseType = 'json' | 'text' | 'binary' | 'stream';
/**
* Extended IncomingMessage with body property (legacy compatibility)
*/
export interface IExtendedIncomingMessage<T = any> extends plugins.http.IncomingMessage {
body: T;
}
/**
* Form field data for multipart/form-data requests
*/
export interface IFormField {
name: string;
value: string | Buffer;
filename?: string;
contentType?: string;
}
/**
* URL encoded form field
*/
export interface IUrlEncodedField {
key: string;
value: string;
}
/**
* Core response object that provides fetch-like API
*/
export interface ICoreResponse<T = any> {
// Properties
ok: boolean;
status: number;
statusText: string;
headers: plugins.http.IncomingHttpHeaders;
url: string;
// Methods
json(): Promise<T>;
text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>;
stream(): NodeJS.ReadableStream;
// Legacy compatibility
raw(): plugins.http.IncomingMessage;
}

View File

@@ -1,16 +1,12 @@
// Legacy API exports (for backward compatibility)
export { request, safeGet } from './legacy/smartrequest.request.js';
export type { IExtendedIncomingMessage } from './legacy/smartrequest.request.js';
export type { ISmartRequestOptions } from './legacy/smartrequest.interfaces.js';
export * from './legacy/smartrequest.jsonrest.js';
export * from './legacy/smartrequest.binaryrest.js';
export * from './legacy/smartrequest.formdata.js';
export * from './legacy/smartrequest.stream.js';
export * from './legacy/index.js';
// Modern API exports
export * from './modern/index.js';
import { SmartRequestClient } from './modern/smartrequestclient.js';
// Core exports for advanced usage
export { SmartResponse, type ICoreRequestOptions, type ICoreResponse } from './core/index.js';
// Default export for easier importing
import { SmartRequestClient } from './modern/smartrequestclient.js';
export default SmartRequestClient;

242
ts/legacy/adapter.ts Normal file
View File

@@ -0,0 +1,242 @@
/**
* Legacy adapter that provides backward compatibility
* Maps legacy API to the new core module
*/
import * as core from '../core/index.js';
import * as plugins from '../core/plugins.js';
const smartpromise = plugins.smartpromise;
// Re-export types for backward compatibility
export { type IExtendedIncomingMessage } from '../core/types.js';
export interface ISmartRequestOptions extends core.ICoreRequestOptions {
autoJsonParse?: boolean;
responseType?: 'json' | 'text' | 'binary' | 'stream';
}
// Re-export interface for form fields
export interface IFormField {
name: string;
type: 'string' | 'filePath' | 'Buffer';
payload: string | Buffer;
fileName?: string;
contentType?: string;
}
/**
* Helper function to convert stream to IExtendedIncomingMessage for legacy compatibility
*/
async function streamToExtendedMessage(
stream: plugins.http.IncomingMessage,
autoJsonParse = true
): Promise<core.IExtendedIncomingMessage> {
const done = smartpromise.defer<core.IExtendedIncomingMessage>();
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
const extendedMessage = stream as core.IExtendedIncomingMessage;
if (autoJsonParse) {
const text = buffer.toString('utf-8');
try {
extendedMessage.body = JSON.parse(text);
} catch (err) {
extendedMessage.body = text;
}
} else {
extendedMessage.body = buffer;
}
done.resolve(extendedMessage);
});
stream.on('error', (err) => {
done.reject(err);
});
return done.promise;
}
/**
* Legacy request function that returns IExtendedIncomingMessage
*/
export async function request(
urlArg: string,
optionsArg: ISmartRequestOptions = {},
responseStreamArg = false,
requestDataFunc?: (req: plugins.http.ClientRequest) => void
): Promise<core.IExtendedIncomingMessage> {
const stream = await core.coreRequest(urlArg, optionsArg, requestDataFunc);
if (responseStreamArg) {
// For stream responses, just cast and return
return stream as core.IExtendedIncomingMessage;
}
// Convert stream to IExtendedIncomingMessage
const autoJsonParse = optionsArg.autoJsonParse !== false;
return streamToExtendedMessage(stream, autoJsonParse);
}
/**
* Safe GET request
*/
export async function safeGet(urlArg: string): Promise<core.IExtendedIncomingMessage | null> {
const agentToUse = urlArg.startsWith('http://')
? new plugins.http.Agent()
: new plugins.https.Agent();
try {
const response = await request(urlArg, {
method: 'GET',
agent: agentToUse,
timeout: 5000,
hardDataCuttingTimeout: 5000,
autoJsonParse: false,
});
return response;
} catch (err) {
console.error(err);
return null;
}
}
/**
* GET JSON request
*/
export async function getJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'GET';
return request(urlArg, optionsArg);
}
/**
* POST JSON request
*/
export async function postJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'POST';
if (
typeof optionsArg.requestBody === 'object' &&
(!optionsArg.headers || !optionsArg.headers['Content-Type'])
) {
// make sure headers exist
if (!optionsArg.headers) {
optionsArg.headers = {};
}
// assign the right Content-Type, leaving all other headers in place
optionsArg.headers = {
...optionsArg.headers,
'Content-Type': 'application/json',
};
}
return request(urlArg, optionsArg);
}
/**
* PUT JSON request
*/
export async function putJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'PUT';
return request(urlArg, optionsArg);
}
/**
* DELETE JSON request
*/
export async function delJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'DELETE';
return request(urlArg, optionsArg);
}
/**
* GET binary data
*/
export async function getBinary(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg = {
...optionsArg,
autoJsonParse: false,
responseType: 'binary'
};
return request(urlArg, optionsArg);
}
/**
* POST form data
*/
export async function postFormData(urlArg: string, formFields: IFormField[], optionsArg: ISmartRequestOptions = {}) {
const form = new plugins.formData();
for (const formField of formFields) {
if (formField.type === 'filePath') {
const fileData = plugins.fs.readFileSync(
plugins.path.isAbsolute(formField.payload as string)
? formField.payload as string
: plugins.path.join(process.cwd(), formField.payload as string)
);
form.append(formField.name, fileData, {
filename: formField.fileName || plugins.path.basename(formField.payload as string),
contentType: formField.contentType
});
} else if (formField.type === 'Buffer') {
form.append(formField.name, formField.payload, {
filename: formField.fileName,
contentType: formField.contentType
});
} else {
form.append(formField.name, formField.payload);
}
}
optionsArg.method = 'POST';
optionsArg.requestBody = form;
if (!optionsArg.headers) {
optionsArg.headers = {};
}
optionsArg.headers = {
...optionsArg.headers,
...form.getHeaders()
};
return request(urlArg, optionsArg);
}
/**
* POST URL encoded form data
*/
export async function postFormDataUrlEncoded(
urlArg: string,
formFields: { key: string; content: string }[],
optionsArg: ISmartRequestOptions = {}
) {
optionsArg.method = 'POST';
if (!optionsArg.headers) {
optionsArg.headers = {};
}
optionsArg.headers['Content-Type'] = 'application/x-www-form-urlencoded';
const urlEncodedBody = formFields
.map(field => `${encodeURIComponent(field.key)}=${encodeURIComponent(field.content)}`)
.join('&');
optionsArg.requestBody = urlEncodedBody;
return request(urlArg, optionsArg);
}
/**
* GET stream
*/
export async function getStream(
urlArg: string,
optionsArg: ISmartRequestOptions = {}
): Promise<plugins.http.IncomingMessage> {
optionsArg.method = 'GET';
const response = await request(urlArg, optionsArg, true);
return response;
}

View File

@@ -1,8 +1,2 @@
export { request, safeGet } from './smartrequest.request.js';
export type { IExtendedIncomingMessage } from './smartrequest.request.js';
export type { ISmartRequestOptions } from './smartrequest.interfaces.js';
export * from './smartrequest.jsonrest.js';
export * from './smartrequest.binaryrest.js';
export * from './smartrequest.formdata.js';
export * from './smartrequest.stream.js';
// Export everything from the legacy adapter
export * from './adapter.js';

View File

@@ -1,33 +0,0 @@
// this file implements methods to get and post binary data.
import * as interfaces from './smartrequest.interfaces.js';
import { request, type IExtendedIncomingMessage } from './smartrequest.request.js';
import * as plugins from './smartrequest.plugins.js';
export const getBinary = async (
domainArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
) => {
optionsArg = {
...optionsArg,
autoJsonParse: false,
};
const done = plugins.smartpromise.defer();
const response = await request(domainArg, optionsArg, true);
const data: Array<Buffer> = [];
response
.on('data', function (chunk: Buffer) {
data.push(chunk);
})
.on('end', function () {
//at this point data is an array of Buffers
//so Buffer.concat() can make us a new Buffer
//of all of them together
const buffer = Buffer.concat(data);
response.body = buffer;
done.resolve();
});
await done.promise;
return response as IExtendedIncomingMessage<Buffer>;
};

View File

@@ -1,99 +0,0 @@
import * as plugins from './smartrequest.plugins.js';
import * as interfaces from './smartrequest.interfaces.js';
import { request } from './smartrequest.request.js';
/**
* the interfae for FormFieldData
*/
export interface IFormField {
name: string;
type: 'string' | 'filePath' | 'Buffer';
payload: string | Buffer;
fileName?: string;
contentType?: string;
}
const appendFormField = async (formDataArg: plugins.formData, formDataField: IFormField) => {
switch (formDataField.type) {
case 'string':
formDataArg.append(formDataField.name, formDataField.payload);
break;
case 'filePath':
if (typeof formDataField.payload !== 'string') {
throw new Error(
`Payload for key ${
formDataField.name
} must be of type string. Got ${typeof formDataField.payload} instead.`
);
}
const fileData = plugins.fs.readFileSync(
plugins.path.join(process.cwd(), formDataField.payload)
);
formDataArg.append('file', fileData, {
filename: formDataField.fileName ? formDataField.fileName : 'upload.pdf',
contentType: 'application/pdf',
});
break;
case 'Buffer':
formDataArg.append(formDataField.name, formDataField.payload, {
filename: formDataField.fileName ? formDataField.fileName : 'upload.pdf',
contentType: formDataField.contentType ? formDataField.contentType : 'application/pdf',
});
break;
}
};
export const postFormData = async (
urlArg: string,
optionsArg: interfaces.ISmartRequestOptions = {},
payloadArg: IFormField[]
) => {
const form = new plugins.formData();
for (const formField of payloadArg) {
await appendFormField(form, formField);
}
const requestOptions = {
...optionsArg,
method: 'POST',
headers: {
...optionsArg.headers,
...form.getHeaders(),
},
requestBody: form,
};
// lets fire the actual request for sending the formdata
const response = await request(urlArg, requestOptions);
return response;
};
export const postFormDataUrlEncoded = async (
urlArg: string,
optionsArg: interfaces.ISmartRequestOptions = {},
payloadArg: { key: string; content: string }[]
) => {
let resultString = '';
for (const keyContentPair of payloadArg) {
if (resultString) {
resultString += '&';
}
resultString += `${encodeURIComponent(keyContentPair.key)}=${encodeURIComponent(
keyContentPair.content
)}`;
}
const requestOptions: interfaces.ISmartRequestOptions = {
...optionsArg,
method: 'POST',
headers: {
...optionsArg.headers,
'content-type': 'application/x-www-form-urlencoded',
},
requestBody: resultString,
};
// lets fire the actual request for sending the formdata
const response = await request(urlArg, requestOptions);
return response;
};

View File

@@ -1,10 +0,0 @@
import * as plugins from './smartrequest.plugins.js';
import * as https from 'https';
export interface ISmartRequestOptions extends https.RequestOptions {
keepAlive?: boolean;
requestBody?: any;
autoJsonParse?: boolean;
queryParams?: { [key: string]: string };
hardDataCuttingTimeout?: number;
}

View File

@@ -1,63 +0,0 @@
// This file implements methods to get and post JSON in a simple manner.
import * as interfaces from './smartrequest.interfaces.js';
import { request } from './smartrequest.request.js';
/**
* gets Json and puts the right headers + handles response aggregation
* @param domainArg
* @param optionsArg
*/
export const getJson = async (
domainArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
) => {
optionsArg.method = 'GET';
optionsArg.headers = {
...optionsArg.headers,
};
let response = await request(domainArg, optionsArg);
return response;
};
export const postJson = async (
domainArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
) => {
optionsArg.method = 'POST';
if (
typeof optionsArg.requestBody === 'object' &&
(!optionsArg.headers || !optionsArg.headers['Content-Type'])
) {
// make sure headers exist
if (!optionsArg.headers) {
optionsArg.headers = {};
}
// assign the right Content-Type, leaving all other headers in place
optionsArg.headers = {
...optionsArg.headers,
'Content-Type': 'application/json',
};
}
let response = await request(domainArg, optionsArg);
return response;
};
export const putJson = async (
domainArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
) => {
optionsArg.method = 'PUT';
let response = await request(domainArg, optionsArg);
return response;
};
export const delJson = async (
domainArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
) => {
optionsArg.method = 'DELETE';
let response = await request(domainArg, optionsArg);
return response;
};

View File

@@ -1,231 +0,0 @@
import * as plugins from './smartrequest.plugins.js';
import * as interfaces from './smartrequest.interfaces.js';
export interface IExtendedIncomingMessage<T = any> extends plugins.http.IncomingMessage {
body: T;
}
const buildUtf8Response = (
incomingMessageArg: plugins.http.IncomingMessage,
autoJsonParse = true
): Promise<IExtendedIncomingMessage> => {
const done = plugins.smartpromise.defer<IExtendedIncomingMessage>();
// Continuously update stream with data
let body = '';
incomingMessageArg.on('data', (chunkArg) => {
body += chunkArg;
});
incomingMessageArg.on('end', () => {
if (autoJsonParse) {
try {
(incomingMessageArg as IExtendedIncomingMessage).body = JSON.parse(body);
} catch (err) {
(incomingMessageArg as IExtendedIncomingMessage).body = body;
}
} else {
(incomingMessageArg as IExtendedIncomingMessage).body = body;
}
done.resolve(incomingMessageArg as IExtendedIncomingMessage);
});
return done.promise;
};
/**
* determine wether a url is a unix sock
* @param urlArg
*/
const testForUnixSock = (urlArg: string): boolean => {
const unixRegex = /^(http:\/\/|https:\/\/|)unix:/;
return unixRegex.test(urlArg);
};
/**
* determine socketPath and path for unixsock
*/
const parseSocketPathAndRoute = (stringToParseArg: string) => {
const parseRegex = /(.*):(.*)/;
const result = parseRegex.exec(stringToParseArg);
return {
socketPath: result[1],
path: result[2],
};
};
/**
* a custom http agent to make sure we can set custom keepAlive options for speedy subsequent calls
*/
const httpAgent = new plugins.agentkeepalive({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
timeout: 60000,
});
/**
* a custom http agent to make sure we can set custom keepAlive options for speedy subsequent calls
*/
const httpAgentKeepAliveFalse = new plugins.agentkeepalive({
keepAlive: false,
timeout: 60000,
});
/**
* a custom https agent to make sure we can set custom keepAlive options for speedy subsequent calls
*/
const httpsAgent = new plugins.agentkeepalive.HttpsAgent({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
timeout: 60000,
});
/**
* a custom https agent to make sure we can set custom keepAlive options for speedy subsequent calls
*/
const httpsAgentKeepAliveFalse = new plugins.agentkeepalive.HttpsAgent({
keepAlive: false,
timeout: 60000,
});
export let request = async (
urlArg: string,
optionsArg: interfaces.ISmartRequestOptions = {},
responseStreamArg: boolean = false,
requestDataFunc: (req: plugins.http.ClientRequest) => void = null
): Promise<IExtendedIncomingMessage> => {
const done = plugins.smartpromise.defer<IExtendedIncomingMessage>();
// merge options
const defaultOptions: interfaces.ISmartRequestOptions = {
// agent: agent,
autoJsonParse: true,
keepAlive: true,
};
optionsArg = {
...defaultOptions,
...optionsArg,
};
// parse url
const parsedUrl = plugins.smarturl.Smarturl.createFromUrl(urlArg, {
searchParams: optionsArg.queryParams || {},
});
optionsArg.hostname = parsedUrl.hostname;
if (parsedUrl.port) {
optionsArg.port = parseInt(parsedUrl.port, 10);
}
optionsArg.path = parsedUrl.path;
optionsArg.queryParams = parsedUrl.searchParams;
// determine if unixsock
if (testForUnixSock(urlArg)) {
const detailedUnixPath = parseSocketPathAndRoute(optionsArg.path);
optionsArg.socketPath = detailedUnixPath.socketPath;
optionsArg.path = detailedUnixPath.path;
}
// TODO: support tcp sockets
// lets determine agent
switch (true) {
case !!optionsArg.agent:
break;
case parsedUrl.protocol === 'https:' && optionsArg.keepAlive:
optionsArg.agent = httpsAgent;
break;
case parsedUrl.protocol === 'https:' && !optionsArg.keepAlive:
optionsArg.agent = httpsAgentKeepAliveFalse;
break;
case parsedUrl.protocol === 'http:' && optionsArg.keepAlive:
optionsArg.agent = httpAgent;
break;
case parsedUrl.protocol === 'http:' && !optionsArg.keepAlive:
optionsArg.agent = httpAgentKeepAliveFalse;
break;
}
// lets determine the request module to use
const requestModule = (() => {
switch (true) {
case parsedUrl.protocol === 'https:':
return plugins.https;
case parsedUrl.protocol === 'http:':
return plugins.http;
}
})() as typeof plugins.https;
if (!requestModule) {
console.error(`The request to ${urlArg} is missing a viable protocol. Must be http or https`);
return;
}
// lets perform the actual request
const requestToFire = requestModule.request(optionsArg, async (resArg) => {
if (optionsArg.hardDataCuttingTimeout) {
setTimeout(() => {
resArg.destroy();
done.reject(new Error('Request timed out'));
}, optionsArg.hardDataCuttingTimeout)
}
if (responseStreamArg) {
done.resolve(resArg as IExtendedIncomingMessage);
} else {
const builtResponse = await buildUtf8Response(resArg, optionsArg.autoJsonParse);
done.resolve(builtResponse);
}
});
// lets write the requestBody
if (optionsArg.requestBody) {
if (optionsArg.requestBody instanceof plugins.formData) {
optionsArg.requestBody.pipe(requestToFire).on('finish', (event: any) => {
requestToFire.end();
});
} else {
if (typeof optionsArg.requestBody !== 'string') {
optionsArg.requestBody = JSON.stringify(optionsArg.requestBody);
}
requestToFire.write(optionsArg.requestBody);
requestToFire.end();
}
} else if (requestDataFunc) {
requestDataFunc(requestToFire);
} else {
requestToFire.end();
}
// lets handle an error
requestToFire.on('error', (e) => {
console.error(e);
requestToFire.destroy();
});
const response = await done.promise;
response.on('error', (err) => {
console.log(err);
response.destroy();
});
return response;
};
export const safeGet = async (urlArg: string) => {
const agentToUse = urlArg.startsWith('http://') ? new plugins.http.Agent() : new plugins.https.Agent();
try {
const response = await request(urlArg, {
method: 'GET',
agent: agentToUse,
timeout: 5000,
hardDataCuttingTimeout: 5000,
autoJsonParse: false,
});
return response;
} catch (err) {
console.log(err);
return null;
}
};

View File

@@ -1,17 +0,0 @@
import * as plugins from './smartrequest.plugins.js';
import * as interfaces from './smartrequest.interfaces.js';
import { request } from './smartrequest.request.js';
export const getStream = async (
urlArg: string,
optionsArg: interfaces.ISmartRequestOptions = {}
): Promise<plugins.http.IncomingMessage> => {
try {
// Call the existing request function with responseStreamArg set to true.
const responseStream = await request(urlArg, optionsArg, true);
return responseStream;
} catch (err) {
console.error('An error occurred while getting the stream:', err);
throw err; // Rethrow the error to be handled by the caller.
}
};

View File

@@ -1,19 +1,22 @@
import { type IExtendedIncomingMessage } from '../../legacy/smartrequest.request.js';
import { type SmartResponse } from '../../core/index.js';
import { type TPaginationConfig, PaginationStrategy, type TPaginatedResponse } from '../types/pagination.js';
/**
* Creates a paginated response from a regular response
*/
export function createPaginatedResponse<T>(
response: IExtendedIncomingMessage<any>,
export async function createPaginatedResponse<T>(
response: SmartResponse<any>,
paginationConfig: TPaginationConfig,
queryParams: Record<string, string>,
fetchNextPage: (params: Record<string, string>) => Promise<TPaginatedResponse<T>>
): TPaginatedResponse<T> {
): Promise<TPaginatedResponse<T>> {
// Parse response body first
const body = await response.json();
// Default to response.body for items if response is JSON
let items: T[] = Array.isArray(response.body)
? response.body
: (response.body?.items || response.body?.data || response.body?.results || []);
let items: T[] = Array.isArray(body)
? body
: (body?.items || body?.data || body?.results || []);
let hasNextPage = false;
let nextPageParams: Record<string, string> = {};
@@ -24,7 +27,7 @@ export function createPaginatedResponse<T>(
const config = paginationConfig;
const currentPage = parseInt(queryParams[config.pageParam || 'page'] || String(config.startPage || 1));
const limit = parseInt(queryParams[config.limitParam || 'limit'] || String(config.pageSize || 20));
const total = getValueByPath(response.body, config.totalPath || 'total') || 0;
const total = getValueByPath(body, config.totalPath || 'total') || 0;
hasNextPage = currentPage * limit < total;
@@ -39,8 +42,8 @@ export function createPaginatedResponse<T>(
case PaginationStrategy.CURSOR: {
const config = paginationConfig;
const nextCursor = getValueByPath(response.body, config.cursorPath || 'nextCursor');
const hasMore = getValueByPath(response.body, config.hasMorePath || 'hasMore');
const nextCursor = getValueByPath(body, config.cursorPath || 'nextCursor');
const hasMore = getValueByPath(body, config.hasMorePath || 'hasMore');
hasNextPage = !!nextCursor || !!hasMore;

View File

@@ -1,6 +1,9 @@
// Export the main client
export { SmartRequestClient } from './smartrequestclient.js';
// Export response type from core
export { SmartResponse } from '../core/index.js';
// Export types
export type { HttpMethod, ResponseType, FormField, RetryConfig, TimeoutConfig } from './types/common.js';
export {
@@ -34,12 +37,12 @@ export function createFormClient<T = any>() {
* Create a client pre-configured for binary data
*/
export function createBinaryClient<T = any>() {
return SmartRequestClient.create<T>().responseType('binary');
return SmartRequestClient.create<T>().accept('binary');
}
/**
* Create a client pre-configured for streaming
*/
export function createStreamClient() {
return SmartRequestClient.create().responseType('stream');
return SmartRequestClient.create().accept('stream');
}

View File

@@ -1,6 +1,5 @@
import { type ISmartRequestOptions } from '../legacy/smartrequest.interfaces.js';
import { request, type IExtendedIncomingMessage } from '../legacy/smartrequest.request.js';
import * as plugins from '../legacy/smartrequest.plugins.js';
import { request, SmartResponse, type ICoreRequestOptions } from '../core/index.js';
import * as plugins from '../core/plugins.js';
import type { HttpMethod, ResponseType, FormField } from './types/common.js';
import {
@@ -18,9 +17,7 @@ import { createPaginatedResponse } from './features/pagination.js';
*/
export class SmartRequestClient<T = any> {
private _url: string;
private _options: ISmartRequestOptions = {};
private _responseType: ResponseType = 'json';
private _timeoutMs: number = 60000;
private _options: ICoreRequestOptions = {};
private _retries: number = 0;
private _queryParams: Record<string, string> = {};
private _paginationConfig?: TPaginationConfig;
@@ -94,7 +91,6 @@ export class SmartRequestClient<T = any> {
* Set request timeout in milliseconds
*/
timeout(ms: number): this {
this._timeoutMs = ms;
this._options.timeout = ms;
this._options.hardDataCuttingTimeout = ms;
return this;
@@ -145,16 +141,18 @@ export class SmartRequestClient<T = any> {
}
/**
* Set response type
* Set the Accept header to indicate what content type is expected
*/
responseType(type: ResponseType): this {
this._responseType = type;
if (type === 'binary' || type === 'stream') {
this._options.autoJsonParse = false;
}
return this;
accept(type: ResponseType): this {
// Map response types to Accept header values
const acceptHeaders: Record<ResponseType, string> = {
'json': 'application/json',
'text': 'text/plain',
'binary': 'application/octet-stream',
'stream': '*/*'
};
return this.header('Accept', acceptHeaders[type]);
}
/**
@@ -225,35 +223,35 @@ export class SmartRequestClient<T = any> {
/**
* Make a GET request
*/
async get<R = T>(): Promise<IExtendedIncomingMessage<R>> {
async get<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('GET');
}
/**
* Make a POST request
*/
async post<R = T>(): Promise<IExtendedIncomingMessage<R>> {
async post<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('POST');
}
/**
* Make a PUT request
*/
async put<R = T>(): Promise<IExtendedIncomingMessage<R>> {
async put<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('PUT');
}
/**
* Make a DELETE request
*/
async delete<R = T>(): Promise<IExtendedIncomingMessage<R>> {
async delete<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('DELETE');
}
/**
* Make a PATCH request
*/
async patch<R = T>(): Promise<IExtendedIncomingMessage<R>> {
async patch<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('PATCH');
}
@@ -272,7 +270,7 @@ export class SmartRequestClient<T = any> {
const response = await this.execute();
return createPaginatedResponse<ItemType>(
return await createPaginatedResponse<ItemType>(
response,
this._paginationConfig,
this._queryParams,
@@ -298,7 +296,7 @@ export class SmartRequestClient<T = any> {
/**
* Execute the HTTP request
*/
private async execute<R = T>(method?: HttpMethod): Promise<IExtendedIncomingMessage<R>> {
private async execute<R = T>(method?: HttpMethod): Promise<SmartResponse<R>> {
if (method) {
this._options.method = method;
}
@@ -310,28 +308,8 @@ export class SmartRequestClient<T = any> {
for (let attempt = 0; attempt <= this._retries; attempt++) {
try {
if (this._responseType === 'stream') {
return await request(this._url, this._options, true) as IExtendedIncomingMessage<R>;
} else if (this._responseType === 'binary') {
const response = await request(this._url, this._options, true);
// Handle binary response
const dataPromise = plugins.smartpromise.defer<Buffer>();
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => {
const buffer = Buffer.concat(chunks);
(response as IExtendedIncomingMessage<R>).body = buffer as any;
dataPromise.resolve();
});
await dataPromise.promise;
return response as IExtendedIncomingMessage<R>;
} else {
// Handle JSON or text response
return await request(this._url, this._options) as IExtendedIncomingMessage<R>;
}
const response = await request(this._url, this._options);
return response as SmartResponse<R>;
} catch (error) {
lastError = error as Error;

View File

@@ -1,4 +1,4 @@
import { type IExtendedIncomingMessage } from '../../legacy/smartrequest.request.js';
import { type SmartResponse } from '../../core/index.js';
/**
* Pagination strategy options
@@ -45,8 +45,8 @@ export interface LinkPaginationConfig {
*/
export interface CustomPaginationConfig {
strategy: PaginationStrategy.CUSTOM;
hasNextPage: (response: IExtendedIncomingMessage<any>) => boolean;
getNextPageParams: (response: IExtendedIncomingMessage<any>, currentParams: Record<string, string>) => Record<string, string>;
hasNextPage: (response: SmartResponse<any>) => boolean;
getNextPageParams: (response: SmartResponse<any>, currentParams: Record<string, string>) => Record<string, string>;
}
/**
@@ -62,5 +62,5 @@ export interface TPaginatedResponse<T> {
hasNextPage: boolean; // Whether there are more pages
getNextPage: () => Promise<TPaginatedResponse<T>>; // Function to get the next page
getAllPages: () => Promise<T[]>; // Function to get all remaining pages and combine
response: IExtendedIncomingMessage<any>; // Original response
response: SmartResponse<any>; // Original response
}