diff --git a/examples/login-with-custom-http-library.ts b/examples/login-with-custom-http-library.ts new file mode 100644 index 0000000..609ee33 --- /dev/null +++ b/examples/login-with-custom-http-library.ts @@ -0,0 +1,104 @@ +/** + * This example demonstrates how to use a custom HTTP library with steam-session + * instead of the default @doctormckay/stdlib HttpClient. + * + * This allows you to use popular HTTP libraries like got, axios, node-fetch, or request. + */ + +import { + LoginSession, + EAuthTokenPlatformType, + CustomRequestFunction +} from '../src'; + +import got from 'got'; +import { SocksProxyAgent } from 'socks-proxy-agent'; + + +async function exampleWithProxy() { + const sessionId = Math.random().toString(36).substring(2, 10); // Generate a random session ID for proxy authentication + // Using proxy - comment out if you don't have valid proxy credentials + const proxyAgent = new SocksProxyAgent(`socks5://xxxx-${sessionId}:xxx@proxy.xxxx.xxx:3120`); + + const customRequest: CustomRequestFunction = async (options) => { + try { + // Prepare got options + const gotOptions: any = { + method: options.method, + searchParams: options.queryParams, + agent: { // Comment out if not using proxy + https: proxyAgent + }, + followRedirect: true, + responseType: 'buffer', // Use buffer to handle both text and binary responses + timeout: { + request: 10000 // 10 second timeout + }, + headers: options.headers + }; + + // Handle body if provided (already encoded, e.g. as multipart/form-data) + if (options.body) { + gotOptions.body = options.body; + } + + console.log('Making request to:', options.url); + console.log('Method:', options.method); + console.log('Headers:', gotOptions.headers); + console.log('Body type:', options.body ? (Buffer.isBuffer(options.body) ? 'Buffer' : 'string') : 'none'); + + const response = await got(options.url, gotOptions); + + return { + statusCode: response.statusCode, + headers: response.headers, + body: response.body, // This will be a Buffer + finalUrl: response.url + }; + } catch (error: any) { + console.error('Request failed:', error.message); + if (error.response) { + console.error('Response status:', error.response.statusCode); + console.error('Response body:', error.response.body?.toString()); + return { + statusCode: error.response.statusCode, + headers: error.response.headers, + body: error.response.body, + finalUrl: error.response.url + }; + } + throw error; + } + }; + + let session = new LoginSession(EAuthTokenPlatformType.WebBrowser, { + customRequestFunction: customRequest + }); + + session.on('authenticated', async () => { + console.log('Successfully authenticated!'); + + // Get web cookies + let cookies = await session.getWebCookies(); + console.log('Got cookies:', cookies); + }); + + session.on('timeout', () => { + console.log('Login timed out'); + }); + + session.on('error', (err) => { + console.error('Login error:', err); + }); + + // Start login + let startResult = await session.startWithCredentials({ + accountName: 'xxx', + password: 'xxxxx' + }); + + console.log('Login started:', startResult); + +} + +exampleWithProxy(); \ No newline at end of file diff --git a/src/HttpClientAdapter.ts b/src/HttpClientAdapter.ts new file mode 100644 index 0000000..d6fd946 --- /dev/null +++ b/src/HttpClientAdapter.ts @@ -0,0 +1,171 @@ +import {CustomRequestFunction, CustomRequestOptions, CustomRequestResponse} from './interfaces-external'; + +/** + * Adapter that wraps a custom request function to match the HttpClient interface + * expected by the rest of the library. + */ +export default class HttpClientAdapter { + private _customRequestFunction: CustomRequestFunction; + private _userAgent?: string; + + constructor(customRequestFunction: CustomRequestFunction) { + this._customRequestFunction = customRequestFunction; + } + + /** + * User-agent string to include in requests + */ + get userAgent(): string | undefined { + return this._userAgent; + } + + set userAgent(value: string) { + this._userAgent = value; + } + + /** + * Static method to convert a plain object to multipart form data. + * This is called by HttpClient.simpleObjectToMultipartForm() in the existing code. + * + * For the adapter, we'll return the object directly and handle conversion in the request method. + */ + static simpleObjectToMultipartForm(obj: any): any { + return obj; + } + + /** + * Perform an HTTP request using the custom request function. + * This converts between the HttpClient interface and the CustomRequestFunction interface. + */ + async request(options: HttpClientRequestOptions): Promise { + const headers = {...(options.headers || {})}; + + // Add user-agent if set + if (this._userAgent) { + headers['user-agent'] = this._userAgent; + } + + let body: string | Buffer | undefined; + + // Handle multipart form data - create it manually like HttpClient does + if (options.multipartForm) { + const boundary = '-----------------------------' + Date.now().toString() + Math.random().toString().slice(2); + headers['content-type'] = `multipart/form-data; boundary=${boundary}`; + + const encodedBodyParts: Buffer[] = []; + for (const key in options.multipartForm) { + const formObject = options.multipartForm[key]; + const content = formObject.content; + + let head = `--${boundary}\r\nContent-Disposition: form-data; name="${key}"`; + if (formObject.filename) { + head += `; filename="${formObject.filename}"`; + } + if (formObject.contentType) { + head += `\r\nContent-Type: ${formObject.contentType}`; + } + head += '\r\n\r\n'; + + encodedBodyParts.push(Buffer.from(head, 'utf8')); + encodedBodyParts.push(Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')); + encodedBodyParts.push(Buffer.from('\r\n', 'utf8')); + } + encodedBodyParts.push(Buffer.from(`--${boundary}--\r\n`, 'utf8')); + body = Buffer.concat(encodedBodyParts); + } + + // Prepare custom request options + const customOptions: CustomRequestOptions = { + method: options.method, + url: options.url, + headers, + queryParams: options.queryString, + body: body // Pass Buffer directly + }; + + // Execute the custom request function + const customResponse: CustomRequestResponse = await this._customRequestFunction(customOptions); + + // Parse response body + let responseBody: string | Buffer = customResponse.body; + if (typeof responseBody !== 'string' && !Buffer.isBuffer(responseBody)) { + responseBody = String(responseBody); + } + + let jsonBody: any; + let textBody: string; + let rawBody: Buffer; + + if (Buffer.isBuffer(responseBody)) { + rawBody = responseBody; + textBody = responseBody.toString('utf8'); + } else { + textBody = responseBody; + rawBody = Buffer.from(responseBody, 'utf8'); + } + + // Try to parse as JSON if content-type indicates JSON + const responseContentType = this._getHeader(customResponse.headers, 'content-type'); + if (responseContentType && responseContentType.includes('application/json')) { + try { + jsonBody = JSON.parse(textBody); + } catch (err) { + // Not valid JSON, leave jsonBody undefined + } + } + + // Convert response to HttpClient format + return { + statusCode: customResponse.statusCode, + headers: this._normalizeHeaders(customResponse.headers), + url: customResponse.finalUrl, + jsonBody, + textBody, + rawBody + }; + } + + /** + * Get a header value case-insensitively + */ + private _getHeader(headers: Record, name: string): string | undefined { + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) { + return Array.isArray(value) ? value[0] : value; + } + } + return undefined; + } + + /** + * Normalize headers to lowercase keys (HttpClient convention) + */ + private _normalizeHeaders(headers: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + normalized[key.toLowerCase()] = value; + } + return normalized; + } +} + +/** + * Local interfaces matching HttpClient's expected format + */ +interface HttpClientRequestOptions { + method: 'GET' | 'POST'; + url: string; + headers?: Record; + queryString?: Record; + multipartForm?: any; +} + +interface HttpClientResponse { + statusCode: number; + headers: Record; + url: string; + jsonBody?: any; + textBody?: string; + rawBody?: Buffer; +} diff --git a/src/LoginSession.ts b/src/LoginSession.ts index 242dd7c..2b9555f 100644 --- a/src/LoginSession.ts +++ b/src/LoginSession.ts @@ -9,6 +9,7 @@ import {TypedEmitter} from 'tiny-typed-emitter'; import AuthenticationClient from './AuthenticationClient'; import {API_HEADERS, decodeJwt, eresultError, defaultUserAgent} from './helpers'; +import HttpClientAdapter from './HttpClientAdapter'; import WebApiTransport from './transports/WebApiTransport'; import WebSocketCMTransport from './transports/WebSocketCMTransport'; @@ -111,6 +112,14 @@ export default class LoginSession extends TypedEmitter { throw new Error('Cannot specify more than one of localAddress, httpProxy, socksProxy, or agent at the same time'); } + // Check if customRequestFunction is used with mutually exclusive options + if (options.customRequestFunction) { + let conflictingOptions = Object.keys(options).filter(k => mutuallyExclusiveOptions.includes(k)); + if (conflictingOptions.length > 0) { + throw new Error(`Cannot use customRequestFunction with ${conflictingOptions.join(', ')}. Your custom request function should handle proxy/agent configuration.`); + } + } + let agent:HTTPS.Agent = options.agent || new HTTPS.Agent({keepAlive: true}); if (options.httpProxy) { @@ -119,10 +128,15 @@ export default class LoginSession extends TypedEmitter { agent = new SocksProxyAgent(options.socksProxy); } - this._webClient = new HttpClient({ - httpsAgent: agent, - localAddress: options.localAddress - }); + // Use custom request function if provided, otherwise use standard HttpClient + if (options.customRequestFunction) { + this._webClient = new HttpClientAdapter(options.customRequestFunction) as any; + } else { + this._webClient = new HttpClient({ + httpsAgent: agent, + localAddress: options.localAddress + }); + } this._platformType = platformType; diff --git a/src/index.ts b/src/index.ts index 103ddc8..7b53b5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,15 @@ export { LoginApprover, LoginSession }; + +export type { + CustomRequestFunction, + CustomRequestOptions, + CustomRequestResponse, + ConstructorOptions, + StartLoginSessionWithCredentialsDetails, + StartSessionResponse, + StartSessionResponseValidAction, + AuthSessionInfo, + ApproveAuthSessionRequest +} from './interfaces-external'; diff --git a/src/interfaces-external.ts b/src/interfaces-external.ts index eab7a9c..c73149e 100644 --- a/src/interfaces-external.ts +++ b/src/interfaces-external.ts @@ -14,6 +14,67 @@ import EAuthTokenPlatformType from './enums-steam/EAuthTokenPlatformType'; import EAuthSessionSecurityHistory from './enums-steam/EAuthSessionSecurityHistory'; import ITransport from './transports/ITransport'; +/** + * Options for a custom HTTP request function that can be used instead of the default HttpClient. + */ +export interface CustomRequestOptions { + /** + * HTTP method (GET or POST) + */ + method: 'GET' | 'POST'; + + /** + * Full URL to request + */ + url: string; + + /** + * Optional HTTP headers + */ + headers?: Record; + + /** + * Optional query parameters to append to the URL + */ + queryParams?: Record; + + /** + * Optional request body (already encoded, e.g. as multipart/form-data for POST requests). + * Can be a string or Buffer. + */ + body?: string | Buffer; +} + +/** + * Response from a custom HTTP request function. + */ +export interface CustomRequestResponse { + /** + * HTTP status code + */ + statusCode: number; + + /** + * HTTP response headers. Header names should be lowercase. + */ + headers: Record; + + /** + * Response body as a string or Buffer + */ + body: string | Buffer; + + /** + * Final URL after any redirects + */ + finalUrl: string; +} + +/** + * A function that performs an HTTP request with the given options and returns a response. + */ +export type CustomRequestFunction = (options: CustomRequestOptions) => Promise; + export interface ConstructorOptions { /** * An `ITransport` instance, if you need to specify a custom transport. If omitted, defaults to a @@ -70,6 +131,68 @@ export interface ConstructorOptions { * a random machine name in the format DESKTOP-ABCDEFG will be generated automatically. */ machineFriendlyName?: string; + + /** + * A custom function to use for all HTTP requests instead of the default HttpClient from @doctormckay/stdlib. + * This allows you to use your own HTTP library (e.g., got, axios, node-fetch, request). + * + * Your function should accept {@link CustomRequestOptions} and return a Promise that resolves to {@link CustomRequestResponse}. + * + * When this option is provided, {@link ConstructorOptions.agent}, {@link ConstructorOptions.httpProxy}, + * {@link ConstructorOptions.socksProxy}, and {@link ConstructorOptions.localAddress} options will be ignored, + * and an error will be thrown if you try to use them together. Your custom function is responsible for handling + * any proxy, agent, or network configuration. + * + * **Example with `got`:** + * ```js + * import got from 'got'; + * import {LoginSession, EAuthTokenPlatformType} from 'steam-session'; + * + * let session = new LoginSession(EAuthTokenPlatformType.WebBrowser, { + * customRequestFunction: async (options) => { + * const response = await got(options.url, { + * method: options.method, + * headers: options.headers, + * searchParams: options.queryParams, + * body: options.body, + * followRedirect: true + * }); + * return { + * statusCode: response.statusCode, + * headers: response.headers, + * body: response.body, + * finalUrl: response.url + * }; + * } + * }); + * ``` + * + * **Example with `axios`:** + * ```js + * import axios from 'axios'; + * import {LoginSession, EAuthTokenPlatformType} from 'steam-session'; + * + * let session = new LoginSession(EAuthTokenPlatformType.WebBrowser, { + * customRequestFunction: async (options) => { + * const response = await axios({ + * method: options.method, + * url: options.url, + * headers: options.headers, + * params: options.queryParams, + * data: options.body, + * maxRedirects: 5 + * }); + * return { + * statusCode: response.status, + * headers: response.headers, + * body: response.data, + * finalUrl: response.request.res.responseUrl + * }; + * } + * }); + * ``` + */ + customRequestFunction?: CustomRequestFunction; } export interface StartLoginSessionWithCredentialsDetails {