|
| 1 | +import { createServer, IncomingHttpHeaders } from 'node:http'; |
| 2 | +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 3 | +import { dirname } from 'node:path'; |
| 4 | +import { buffer } from 'node:stream/consumers'; |
| 5 | + |
| 6 | +type RpcRequest = { |
| 7 | + id?: unknown; |
| 8 | + jsonrpc?: string; |
| 9 | + method?: string; |
| 10 | + params?: unknown; |
| 11 | +}; |
| 12 | + |
| 13 | +type RpcResponse = { |
| 14 | + error?: unknown; |
| 15 | + id?: unknown; |
| 16 | + jsonrpc?: string; |
| 17 | + result?: unknown; |
| 18 | +}; |
| 19 | + |
| 20 | +type CacheEntry = { |
| 21 | + error?: unknown; |
| 22 | + jsonrpc: string; |
| 23 | + result?: unknown; |
| 24 | +}; |
| 25 | + |
| 26 | +type CacheData = Record<string, CacheEntry>; |
| 27 | + |
| 28 | +type CacheMetadata = { |
| 29 | + forkBlockNumber: number; |
| 30 | +}; |
| 31 | + |
| 32 | +type CacheFile = { |
| 33 | + metadata: CacheMetadata; |
| 34 | + entries: CacheData; |
| 35 | +}; |
| 36 | + |
| 37 | +export type RpcCachingProxy = { |
| 38 | + proxyUrl: string; |
| 39 | + close: () => void; |
| 40 | + getSummaryLines: () => string[]; |
| 41 | +}; |
| 42 | + |
| 43 | +const CACHEABLE_METHODS = new Set([ |
| 44 | + 'eth_chainId', |
| 45 | + 'eth_gasPrice', |
| 46 | + 'eth_getAccountInfo', |
| 47 | + 'eth_getBalance', |
| 48 | + 'eth_getBlockByNumber', |
| 49 | + 'eth_getCode', |
| 50 | + 'eth_getStorageAt', |
| 51 | + 'eth_getTransactionCount', |
| 52 | + 'eth_getTransactionReceipt', |
| 53 | +]); |
| 54 | + |
| 55 | +function forwardHeaders(headers: IncomingHttpHeaders): Record<string, string> { |
| 56 | + const nextHeaders: Record<string, string> = {}; |
| 57 | + |
| 58 | + for (const [key, value] of Object.entries(headers)) { |
| 59 | + if (typeof value === 'undefined' || key === 'content-length' || key === 'host') { |
| 60 | + continue; |
| 61 | + } |
| 62 | + |
| 63 | + nextHeaders[key] = Array.isArray(value) ? value.join(', ') : value; |
| 64 | + } |
| 65 | + |
| 66 | + return nextHeaders; |
| 67 | +} |
| 68 | + |
| 69 | +function getCacheKey(request: RpcRequest): string | undefined { |
| 70 | + if (typeof request.method !== 'string' || !CACHEABLE_METHODS.has(request.method)) { |
| 71 | + return undefined; |
| 72 | + } |
| 73 | + |
| 74 | + return JSON.stringify([request.method, request.params ?? []]); |
| 75 | +} |
| 76 | + |
| 77 | +function getIdKey(id: unknown): string { |
| 78 | + return JSON.stringify(id ?? null); |
| 79 | +} |
| 80 | + |
| 81 | +function writeCacheFile(cacheFilePath: string, cacheFile: CacheFile) { |
| 82 | + writeFileSync(cacheFilePath, JSON.stringify(cacheFile, null, 2)); |
| 83 | +} |
| 84 | + |
| 85 | +function isCacheFile(value: unknown): value is CacheFile { |
| 86 | + if (!value || typeof value !== 'object') { |
| 87 | + return false; |
| 88 | + } |
| 89 | + |
| 90 | + const maybeCacheFile = value as Partial<CacheFile>; |
| 91 | + return ( |
| 92 | + !!maybeCacheFile.metadata && |
| 93 | + typeof maybeCacheFile.metadata === 'object' && |
| 94 | + typeof maybeCacheFile.metadata.forkBlockNumber === 'number' && |
| 95 | + !!maybeCacheFile.entries && |
| 96 | + typeof maybeCacheFile.entries === 'object' |
| 97 | + ); |
| 98 | +} |
| 99 | + |
| 100 | +function loadCacheData(params: { cacheFilePath: string; metadata: CacheMetadata }): { |
| 101 | + cache: CacheData; |
| 102 | + cacheInvalidated: boolean; |
| 103 | +} { |
| 104 | + const { cacheFilePath, metadata } = params; |
| 105 | + |
| 106 | + if (!existsSync(cacheFilePath)) { |
| 107 | + writeCacheFile(cacheFilePath, { metadata, entries: {} }); |
| 108 | + return { cache: {}, cacheInvalidated: false }; |
| 109 | + } |
| 110 | + |
| 111 | + try { |
| 112 | + const parsed = JSON.parse(readFileSync(cacheFilePath, 'utf8')) as unknown; |
| 113 | + if (isCacheFile(parsed) && parsed.metadata.forkBlockNumber === metadata.forkBlockNumber) { |
| 114 | + return { cache: parsed.entries, cacheInvalidated: false }; |
| 115 | + } |
| 116 | + } catch { |
| 117 | + // Reset invalid cache files below. |
| 118 | + } |
| 119 | + |
| 120 | + writeCacheFile(cacheFilePath, { metadata, entries: {} }); |
| 121 | + return { cache: {}, cacheInvalidated: true }; |
| 122 | +} |
| 123 | + |
| 124 | +export async function startRpcCachingProxy( |
| 125 | + targetUrl: string, |
| 126 | + cacheFilePath: string, |
| 127 | + metadata: CacheMetadata, |
| 128 | +): Promise<RpcCachingProxy> { |
| 129 | + mkdirSync(dirname(cacheFilePath), { recursive: true }); |
| 130 | + |
| 131 | + const { cache, cacheInvalidated } = loadCacheData({ cacheFilePath, metadata }); |
| 132 | + |
| 133 | + const stats = { |
| 134 | + cacheInvalidated, |
| 135 | + cacheHits: 0, |
| 136 | + cacheMisses: 0, |
| 137 | + requests: 0, |
| 138 | + upstreamRequests: 0, |
| 139 | + }; |
| 140 | + |
| 141 | + const server = createServer(async (request, response) => { |
| 142 | + try { |
| 143 | + const requestBody = await buffer(request); |
| 144 | + const upstreamRequestBody = new Uint8Array(requestBody.byteLength); |
| 145 | + upstreamRequestBody.set(requestBody); |
| 146 | + |
| 147 | + const parsed = JSON.parse(requestBody.toString('utf8')) as RpcRequest | RpcRequest[]; |
| 148 | + const requests = Array.isArray(parsed) ? parsed : [parsed]; |
| 149 | + |
| 150 | + stats.requests += requests.length; |
| 151 | + |
| 152 | + const cacheKeys = requests.map((item) => getCacheKey(item)); |
| 153 | + const cachedResponses = cacheKeys.map((cacheKey) => (cacheKey ? cache[cacheKey] : undefined)); |
| 154 | + |
| 155 | + if (cachedResponses.every((entry) => entry)) { |
| 156 | + stats.cacheHits += requests.length; |
| 157 | + response.setHeader('content-type', 'application/json'); |
| 158 | + response.statusCode = 200; |
| 159 | + response.end( |
| 160 | + JSON.stringify( |
| 161 | + Array.isArray(parsed) |
| 162 | + ? requests.map((item, index) => ({ id: item.id ?? null, ...cachedResponses[index]! })) |
| 163 | + : { id: requests[0].id ?? null, ...cachedResponses[0]! }, |
| 164 | + ), |
| 165 | + ); |
| 166 | + return; |
| 167 | + } |
| 168 | + |
| 169 | + stats.cacheMisses += cacheKeys.filter( |
| 170 | + (cacheKey, index) => cacheKey && !cachedResponses[index], |
| 171 | + ).length; |
| 172 | + stats.upstreamRequests += 1; |
| 173 | + |
| 174 | + const upstreamResponse = await fetch(targetUrl, { |
| 175 | + method: request.method, |
| 176 | + headers: forwardHeaders(request.headers), |
| 177 | + body: upstreamRequestBody, |
| 178 | + }); |
| 179 | + |
| 180 | + const upstreamText = Buffer.from(await upstreamResponse.arrayBuffer()).toString('utf8'); |
| 181 | + const upstreamResponses = [JSON.parse(upstreamText)].flat() as RpcResponse[]; |
| 182 | + const upstreamById = new Map( |
| 183 | + upstreamResponses.map((item) => [getIdKey(item.id), item] as const), |
| 184 | + ); |
| 185 | + |
| 186 | + let cacheChanged = false; |
| 187 | + |
| 188 | + for (const [index, item] of requests.entries()) { |
| 189 | + const cacheKey = cacheKeys[index]; |
| 190 | + const upstreamItem = upstreamById.get(getIdKey(item.id)); |
| 191 | + if (!cacheKey || !upstreamItem) { |
| 192 | + continue; |
| 193 | + } |
| 194 | + |
| 195 | + cache[cacheKey] = { |
| 196 | + error: upstreamItem.error, |
| 197 | + jsonrpc: upstreamItem.jsonrpc ?? '2.0', |
| 198 | + result: upstreamItem.result, |
| 199 | + }; |
| 200 | + cacheChanged = true; |
| 201 | + } |
| 202 | + |
| 203 | + if (cacheChanged) { |
| 204 | + writeCacheFile(cacheFilePath, { metadata, entries: cache }); |
| 205 | + } |
| 206 | + |
| 207 | + const contentType = upstreamResponse.headers.get('content-type'); |
| 208 | + if (contentType) { |
| 209 | + response.setHeader('content-type', contentType); |
| 210 | + } |
| 211 | + response.statusCode = upstreamResponse.status; |
| 212 | + response.end(upstreamText); |
| 213 | + } catch (error) { |
| 214 | + response.statusCode = 502; |
| 215 | + response.end( |
| 216 | + JSON.stringify({ |
| 217 | + error: error instanceof Error ? error.message : String(error), |
| 218 | + }), |
| 219 | + ); |
| 220 | + } |
| 221 | + }); |
| 222 | + |
| 223 | + server.listen(8449, '0.0.0.0'); |
| 224 | + |
| 225 | + return { |
| 226 | + proxyUrl: `http://host.docker.internal:8449`, |
| 227 | + getSummaryLines: () => [ |
| 228 | + 'RPC proxy cache summary', |
| 229 | + ` invalidated on startup: ${stats.cacheInvalidated ? 'yes' : 'no'}`, |
| 230 | + ` requests: ${stats.requests}`, |
| 231 | + ` cache hits: ${stats.cacheHits}`, |
| 232 | + ` cache misses: ${stats.cacheMisses}`, |
| 233 | + ` upstream HTTP requests: ${stats.upstreamRequests}`, |
| 234 | + ], |
| 235 | + close: () => server.close(), |
| 236 | + }; |
| 237 | +} |
0 commit comments