UNPKG

@chainlink/functions-toolkit

Version:

An NPM package with collection of functions that can be used for working with Chainlink Functions.

502 lines (457 loc) 15.2 kB
// secrets, args & bytesArgs are made available to the user's script // deno-lint-ignore no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars const [secrets, args, bytesArgs] = [ JSON.parse(atob(Deno.args[0])), JSON.parse(atob(Deno.args[1])), JSON.parse(atob(Deno.args[2])), ] const ___1__ = fetch const __2___ = (() => { class Proxy { private server?: Deno.Listener private conns: Deno.Conn[] = [] private httpConns: Deno.HttpConn[] = [] private fetchControllers: AbortController[] = [] private httpQueryCount = 0 constructor( private maxHttpQueries: number, private maxHttpQueryDurationMs: number, private maxHttpQueryUrlLength: number, private maxHttpQueryRequestBytes: number, private maxHttpQueryResponseBytes: number, public port?: number, ) { this.server = Deno.listen({ port: port ?? 0 }) ;(async () => { for await (const conn of this.server!) { this.conns.push(conn) const httpConn = Deno.serveHttp(conn) this.httpConns.push(httpConn) this.handleHttpConnection(httpConn) } })() this.port = (this.server.addr as Deno.NetAddr).port } private handleHttpConnection = async (httpConn: Deno.HttpConn): Promise<void> => { for await (const requestEvent of httpConn) { try { const response = await this.handleProxyRequest(requestEvent) await requestEvent.respondWith(response) } catch { // Client may have already closed connection } } } private handleProxyRequest = async (requestEvent: Deno.RequestEvent): Promise<Response> => { let proxyRequestTimeout: number | undefined try { this.httpQueryCount++ if (this.httpQueryCount > this.maxHttpQueries) { return new Response( JSON.stringify({ error: `Exceeded maximum of ${this.maxHttpQueries} HTTP queries` }), { status: 429, }, ) } const originalUrl = new URL(requestEvent.request.url) const targetUrlParam = originalUrl.searchParams.get('target') if (!targetUrlParam) { return new Response(JSON.stringify({ error: 'Missing target URL parameter' }), { status: 400, }) } const targetUrl = decodeURIComponent(targetUrlParam) if (targetUrl.toString().length > this.maxHttpQueryUrlLength) { return new Response( JSON.stringify({ error: `Destination URL exceeds maximum length of ${this.maxHttpQueryUrlLength}`, }), { status: 414 }, ) } const { result: proxyReqBody, wasSizeExceeded: wasReqPayloadSizeExceeded } = await this.readStreamWithLimit(requestEvent.request.body, this.maxHttpQueryRequestBytes) if (wasReqPayloadSizeExceeded) { return new Response( JSON.stringify({ error: `Request payload size exceeds ${this.maxHttpQueryRequestBytes} byte limit`, }), { status: 413 }, ) } const controller = new AbortController() this.fetchControllers.push(controller) proxyRequestTimeout = setTimeout(() => controller.abort(), this.maxHttpQueryDurationMs) const proxyFetch = await ___1__(targetUrl, { body: proxyReqBody ? proxyReqBody : undefined, cache: requestEvent.request.cache, credentials: requestEvent.request.credentials, headers: requestEvent.request.headers, integrity: requestEvent.request.integrity, keepalive: requestEvent.request.keepalive, method: requestEvent.request.method, mode: requestEvent.request.mode, redirect: requestEvent.request.redirect, referrer: requestEvent.request.referrer, referrerPolicy: requestEvent.request.referrerPolicy, signal: controller.signal, }) clearTimeout(proxyRequestTimeout) const { result: proxyFetchBody, wasSizeExceeded: wasResBodySizeExceeded } = await this.readStreamWithLimit(proxyFetch.body, this.maxHttpQueryResponseBytes) if (wasResBodySizeExceeded) { return new Response( JSON.stringify({ error: `Response payload size exceeds ${this.maxHttpQueryResponseBytes} byte limit`, }), { status: 413 }, ) } return new Response(proxyFetchBody ? proxyFetchBody : undefined, { headers: proxyFetch.headers, status: proxyFetch.status, statusText: proxyFetch.statusText, }) } catch (e) { proxyRequestTimeout ? clearTimeout(proxyRequestTimeout) : null if (e?.name === 'AbortError') { return new Response( JSON.stringify({ error: `HTTP query exceeded time limit of ${this.maxHttpQueryDurationMs}ms`, }), { status: 400, }, ) } return new Response(JSON.stringify({ error: 'Error during fetch request' }), { status: 400, }) } } private readStreamWithLimit = async ( stream: ReadableStream<Uint8Array> | null, maxPayloadSize: number, ): Promise<{ result: Uint8Array | null; wasSizeExceeded: boolean }> => { if (maxPayloadSize > 2 ** 32) { throw new Error('maxPayloadSize must be less than 2^32') } if (!stream) { return { result: null, wasSizeExceeded: false } } const reader = stream.getReader() const chunks: Uint8Array[] = [] let totalLength = 0 // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read() if (value) { if (value.length + totalLength > maxPayloadSize) { await this.cancelReaderAndStream(stream, reader) return { result: null, wasSizeExceeded: true } } chunks.push(value) totalLength += value.length } if (done) { break } } await this.cancelReaderAndStream(stream, reader) if (chunks.length === 0) { return { result: null, wasSizeExceeded: false } } const payload = new Uint8Array(totalLength) let offset = 0 for (const chunk of chunks) { payload.set(chunk, offset) offset += chunk.length } return { result: payload, wasSizeExceeded: false } } private cancelReaderAndStream = async ( stream: ReadableStream<Uint8Array>, reader: ReadableStreamDefaultReader<Uint8Array>, ): Promise<void> => { try { reader.releaseLock() } catch { // Reader may have already been released } try { await reader.cancel() } catch { // Reader may have already been canceled } try { await stream.cancel() } catch { // Stream may have already been canceled } } public close = (): void => { this.server?.close() this.conns.forEach(conn => { try { conn.close() } catch { // Client may have already closed connection } }) this.httpConns.forEach(httpConn => { try { httpConn.close() } catch { // Client may have already closed connection } }) // Abort any pending fetches when the server is closed this.fetchControllers.forEach(controller => { try { controller.abort() } catch { // Controller may have already been aborted } }) } } return new Proxy( Number(Deno.args[3]), // numAllowedQueries Number(Deno.args[4]), // maxQueryDurationMs Number(Deno.args[5]), // maxQueryUrlLength Number(Deno.args[6]), // maxQueryRequestBytes Number(Deno.args[7]), // maxQueryResponseBytes ) })() // Expose a modified version fetch function which routes all requests through the proxy globalThis.fetch = (input: string | Request | URL, init?: RequestInit | undefined) => { const url = new URL(`http://localhost:${__2___.port}`) if (input instanceof Request) { url.searchParams.append('target', encodeURIComponent(input.url.toString())) input = { ...input, url: url.toString(), } } else if (typeof input === 'string' || input instanceof URL) { url.searchParams.append('target', encodeURIComponent(input.toString())) input = url.toString() } else { throw new Error('fetch only accepts string, URL or Request object') } return ___1__(input, init) } interface RequestOptions { url: string method?: HttpMethod params?: Record<string, string> headers?: Record<string, string> data?: Record<string, unknown> timeout?: number responseType?: ResponseType } type HttpMethod = 'get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' type ResponseType = 'json' | 'arraybuffer' | 'document' | 'text' | 'stream' interface SuccessResponse { error: false data?: unknown status: number statusText: string headers?: Record<string, string> } interface ErrorResponse { error: true message?: string code?: string response?: Response } // Functions library for use by user's script // deno-lint-ignore no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars const Functions = { makeHttpRequest: async ({ url, method = 'get', params, headers, data, timeout = 3000, responseType = 'json', }: RequestOptions): Promise<SuccessResponse | ErrorResponse> => { try { if (params) { url += '?' + new URLSearchParams(params).toString() } // Setup controller for timeout const controller = new AbortController() const id = setTimeout(() => controller.abort(), timeout) const result = await fetch(url, { method, headers, body: data ? JSON.stringify(data) : undefined, signal: controller.signal, }) clearTimeout(id) if (result.status >= 400) { const errorResponse: ErrorResponse = { error: true, message: result.statusText, code: result.status.toString(), response: result, } return errorResponse } const successResponse: SuccessResponse = { error: false, status: result.status, statusText: result.statusText, headers: result.headers ? Object.fromEntries(result.headers.entries()) : undefined, } switch (responseType) { case 'json': successResponse.data = await result.json() break case 'arraybuffer': successResponse.data = await result.arrayBuffer() break case 'document': successResponse.data = await result.text() break case 'text': successResponse.data = await result.text() break case 'stream': successResponse.data = result.body break default: throw new Error('invalid response type') } return successResponse } catch (e) { return { error: true, message: e?.toString?.(), } } }, encodeUint256: (num: bigint | number): Uint8Array => { if (typeof num !== 'number' && typeof num !== 'bigint') { throw new Error('input into Functions.encodeUint256 is not a number or bigint') } if (typeof num === 'number') { if (!Number.isInteger(num)) { throw new Error('input into Functions.encodeUint256 is not an integer') } } num = BigInt(num) if (num < 0) { throw new Error('input into Functions.encodeUint256 is negative') } if (num > 2n ** 256n - 1n) { throw new Error('input into Functions.encodeUint256 is too large') } let hexStr = num.toString(16) // Convert to hexadecimal hexStr = hexStr.padStart(64, '0') // Pad with leading zeros if (hexStr.length > 64) { throw new Error('input is too large') } const arr = new Uint8Array(32) for (let i = 0; i < arr.length; i++) { arr[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16) } return arr }, encodeInt256: (num: bigint | number): Uint8Array => { if (typeof num !== 'number' && typeof num !== 'bigint') { throw new Error('input into Functions.encodeInt256 is not a number or bigint') } if (typeof num === 'number') { if (!Number.isInteger(num)) { throw new Error('input into Functions.encodeInt256 is not an integer') } } num = BigInt(num) if (num < -(2n ** 255n)) { throw new Error('input into Functions.encodeInt256 is too small') } if (num > 2n ** 255n - 1n) { throw new Error('input into Functions.encodeInt256 is too large') } let hexStr if (num >= BigInt(0)) { hexStr = num.toString(16) // Convert to hexadecimal } else { // Calculate two's complement for negative numbers const absVal = -num let binStr = absVal.toString(2) // Convert to binary binStr = binStr.padStart(256, '0') // Pad to 256 bits // Invert bits let invertedBinStr = '' for (const bit of binStr) { invertedBinStr += bit === '0' ? '1' : '0' } // Add one let invertedBigInt = BigInt('0b' + invertedBinStr) invertedBigInt += 1n hexStr = invertedBigInt.toString(16) // Convert to hexadecimal } hexStr = hexStr.padStart(64, '0') // Pad with leading zeros if (hexStr.length > 64) { throw new Error('input is too large') } const arr = new Uint8Array(32) for (let i = 0; i < arr.length; i++) { arr[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16) } return arr }, encodeString: (str: string): Uint8Array => { const encoder = new TextEncoder() return encoder.encode(str) }, } try { const userScript = (async () => { //INJECT_USER_CODE_HERE }) as () => Promise<unknown> const result = await userScript() if (!(result instanceof ArrayBuffer) && !(result instanceof Uint8Array)) { throw Error('returned value not an ArrayBuffer or Uint8Array') } const arrayBufferToHex = (input: ArrayBuffer | Uint8Array): string => { let hex = '' const uInt8Array = new Uint8Array(input) uInt8Array.forEach(byte => { hex += byte.toString(16).padStart(2, '0') }) return '0x' + hex } console.log( '\n' + JSON.stringify({ success: arrayBufferToHex(result), }), ) } catch (e: unknown) { let error: Error if (e instanceof Error) { error = e } else if (typeof e === 'string') { error = new Error(e) } else { error = new Error(`invalid value thrown of type ${typeof e}`) } console.log( '\n' + JSON.stringify({ error: { name: error?.name ?? 'Error', message: error?.message ?? 'invalid value returned', details: error?.stack ?? undefined, }, }), ) } finally { __2___.close() }