| 
 | 1 | +import * as fs from 'fs';  | 
 | 2 | +import * as http from 'http';  | 
 | 3 | +import * as https from 'https';  | 
 | 4 | +import type { AddressInfo } from 'net';  | 
 | 5 | +import * as os from 'os';  | 
 | 6 | +import * as path from 'path';  | 
 | 7 | +import * as util from 'util';  | 
 | 8 | +import * as zlib from 'zlib';  | 
 | 9 | +import type { Envelope, EnvelopeItem, Event } from '@sentry/types';  | 
 | 10 | +import { parseEnvelope } from '@sentry/utils';  | 
 | 11 | + | 
 | 12 | +const readFile = util.promisify(fs.readFile);  | 
 | 13 | +const writeFile = util.promisify(fs.writeFile);  | 
 | 14 | + | 
 | 15 | +interface EventProxyServerOptions {  | 
 | 16 | +  /** Port to start the event proxy server at. */  | 
 | 17 | +  port: number;  | 
 | 18 | +  /** The name for the proxy server used for referencing it with listener functions */  | 
 | 19 | +  proxyServerName: string;  | 
 | 20 | +}  | 
 | 21 | + | 
 | 22 | +interface SentryRequestCallbackData {  | 
 | 23 | +  envelope: Envelope;  | 
 | 24 | +  rawProxyRequestBody: string;  | 
 | 25 | +  rawSentryResponseBody: string;  | 
 | 26 | +  sentryResponseStatusCode?: number;  | 
 | 27 | +}  | 
 | 28 | + | 
 | 29 | +/**  | 
 | 30 | + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel`  | 
 | 31 | + * option to this server (like this `tunnel: http://localhost:${port option}/`).  | 
 | 32 | + */  | 
 | 33 | +export async function startEventProxyServer(options: EventProxyServerOptions): Promise<void> {  | 
 | 34 | +  const eventCallbackListeners: Set<(data: string) => void> = new Set();  | 
 | 35 | + | 
 | 36 | +  const proxyServer = http.createServer((proxyRequest, proxyResponse) => {  | 
 | 37 | +    const proxyRequestChunks: Uint8Array[] = [];  | 
 | 38 | + | 
 | 39 | +    proxyRequest.addListener('data', (chunk: Buffer) => {  | 
 | 40 | +      proxyRequestChunks.push(chunk);  | 
 | 41 | +    });  | 
 | 42 | + | 
 | 43 | +    proxyRequest.addListener('error', err => {  | 
 | 44 | +      throw err;  | 
 | 45 | +    });  | 
 | 46 | + | 
 | 47 | +    proxyRequest.addListener('end', () => {  | 
 | 48 | +      const proxyRequestBody =  | 
 | 49 | +        proxyRequest.headers['content-encoding'] === 'gzip'  | 
 | 50 | +          ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString()  | 
 | 51 | +          : Buffer.concat(proxyRequestChunks).toString();  | 
 | 52 | + | 
 | 53 | +      let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]);  | 
 | 54 | + | 
 | 55 | +      if (!envelopeHeader.dsn) {  | 
 | 56 | +        throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.');  | 
 | 57 | +      }  | 
 | 58 | + | 
 | 59 | +      const { origin, pathname, host } = new URL(envelopeHeader.dsn);  | 
 | 60 | + | 
 | 61 | +      const projectId = pathname.substring(1);  | 
 | 62 | +      const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`;  | 
 | 63 | + | 
 | 64 | +      proxyRequest.headers.host = host;  | 
 | 65 | + | 
 | 66 | +      const sentryResponseChunks: Uint8Array[] = [];  | 
 | 67 | + | 
 | 68 | +      const sentryRequest = https.request(  | 
 | 69 | +        sentryIngestUrl,  | 
 | 70 | +        { headers: proxyRequest.headers, method: proxyRequest.method },  | 
 | 71 | +        sentryResponse => {  | 
 | 72 | +          sentryResponse.addListener('data', (chunk: Buffer) => {  | 
 | 73 | +            proxyResponse.write(chunk, 'binary');  | 
 | 74 | +            sentryResponseChunks.push(chunk);  | 
 | 75 | +          });  | 
 | 76 | + | 
 | 77 | +          sentryResponse.addListener('end', () => {  | 
 | 78 | +            eventCallbackListeners.forEach(listener => {  | 
 | 79 | +              const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString();  | 
 | 80 | + | 
 | 81 | +              const data: SentryRequestCallbackData = {  | 
 | 82 | +                envelope: parseEnvelope(proxyRequestBody),  | 
 | 83 | +                rawProxyRequestBody: proxyRequestBody,  | 
 | 84 | +                rawSentryResponseBody,  | 
 | 85 | +                sentryResponseStatusCode: sentryResponse.statusCode,  | 
 | 86 | +              };  | 
 | 87 | + | 
 | 88 | +              listener(Buffer.from(JSON.stringify(data)).toString('base64'));  | 
 | 89 | +            });  | 
 | 90 | +            proxyResponse.end();  | 
 | 91 | +          });  | 
 | 92 | + | 
 | 93 | +          sentryResponse.addListener('error', err => {  | 
 | 94 | +            throw err;  | 
 | 95 | +          });  | 
 | 96 | + | 
 | 97 | +          proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers);  | 
 | 98 | +        },  | 
 | 99 | +      );  | 
 | 100 | + | 
 | 101 | +      sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary');  | 
 | 102 | +      sentryRequest.end();  | 
 | 103 | +    });  | 
 | 104 | +  });  | 
 | 105 | + | 
 | 106 | +  const proxyServerStartupPromise = new Promise<void>(resolve => {  | 
 | 107 | +    proxyServer.listen(options.port, () => {  | 
 | 108 | +      resolve();  | 
 | 109 | +    });  | 
 | 110 | +  });  | 
 | 111 | + | 
 | 112 | +  const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => {  | 
 | 113 | +    eventCallbackResponse.statusCode = 200;  | 
 | 114 | +    eventCallbackResponse.setHeader('connection', 'keep-alive');  | 
 | 115 | + | 
 | 116 | +    const callbackListener = (data: string): void => {  | 
 | 117 | +      eventCallbackResponse.write(data.concat('\n'), 'utf8');  | 
 | 118 | +    };  | 
 | 119 | + | 
 | 120 | +    eventCallbackListeners.add(callbackListener);  | 
 | 121 | + | 
 | 122 | +    eventCallbackRequest.on('close', () => {  | 
 | 123 | +      eventCallbackListeners.delete(callbackListener);  | 
 | 124 | +    });  | 
 | 125 | + | 
 | 126 | +    eventCallbackRequest.on('error', () => {  | 
 | 127 | +      eventCallbackListeners.delete(callbackListener);  | 
 | 128 | +    });  | 
 | 129 | +  });  | 
 | 130 | + | 
 | 131 | +  const eventCallbackServerStartupPromise = new Promise<void>(resolve => {  | 
 | 132 | +    eventCallbackServer.listen(0, () => {  | 
 | 133 | +      const port = String((eventCallbackServer.address() as AddressInfo).port);  | 
 | 134 | +      void registerCallbackServerPort(options.proxyServerName, port).then(resolve);  | 
 | 135 | +    });  | 
 | 136 | +  });  | 
 | 137 | + | 
 | 138 | +  await eventCallbackServerStartupPromise;  | 
 | 139 | +  await proxyServerStartupPromise;  | 
 | 140 | +  return;  | 
 | 141 | +}  | 
 | 142 | + | 
 | 143 | +export async function waitForRequest(  | 
 | 144 | +  proxyServerName: string,  | 
 | 145 | +  callback: (eventData: SentryRequestCallbackData) => Promise<boolean> | boolean,  | 
 | 146 | +): Promise<SentryRequestCallbackData> {  | 
 | 147 | +  const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);  | 
 | 148 | + | 
 | 149 | +  return new Promise<SentryRequestCallbackData>((resolve, reject) => {  | 
 | 150 | +    const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {  | 
 | 151 | +      let eventContents = '';  | 
 | 152 | + | 
 | 153 | +      response.on('error', err => {  | 
 | 154 | +        reject(err);  | 
 | 155 | +      });  | 
 | 156 | + | 
 | 157 | +      response.on('data', (chunk: Buffer) => {  | 
 | 158 | +        const chunkString = chunk.toString('utf8');  | 
 | 159 | +        chunkString.split('').forEach(char => {  | 
 | 160 | +          if (char === '\n') {  | 
 | 161 | +            const eventCallbackData: SentryRequestCallbackData = JSON.parse(  | 
 | 162 | +              Buffer.from(eventContents, 'base64').toString('utf8'),  | 
 | 163 | +            );  | 
 | 164 | +            const callbackResult = callback(eventCallbackData);  | 
 | 165 | +            if (typeof callbackResult !== 'boolean') {  | 
 | 166 | +              callbackResult.then(  | 
 | 167 | +                match => {  | 
 | 168 | +                  if (match) {  | 
 | 169 | +                    response.destroy();  | 
 | 170 | +                    resolve(eventCallbackData);  | 
 | 171 | +                  }  | 
 | 172 | +                },  | 
 | 173 | +                err => {  | 
 | 174 | +                  throw err;  | 
 | 175 | +                },  | 
 | 176 | +              );  | 
 | 177 | +            } else if (callbackResult) {  | 
 | 178 | +              response.destroy();  | 
 | 179 | +              resolve(eventCallbackData);  | 
 | 180 | +            }  | 
 | 181 | +            eventContents = '';  | 
 | 182 | +          } else {  | 
 | 183 | +            eventContents = eventContents.concat(char);  | 
 | 184 | +          }  | 
 | 185 | +        });  | 
 | 186 | +      });  | 
 | 187 | +    });  | 
 | 188 | + | 
 | 189 | +    request.end();  | 
 | 190 | +  });  | 
 | 191 | +}  | 
 | 192 | + | 
 | 193 | +export function waitForEnvelopeItem(  | 
 | 194 | +  proxyServerName: string,  | 
 | 195 | +  callback: (envelopeItem: EnvelopeItem) => Promise<boolean> | boolean,  | 
 | 196 | +): Promise<EnvelopeItem> {  | 
 | 197 | +  return new Promise((resolve, reject) => {  | 
 | 198 | +    waitForRequest(proxyServerName, async eventData => {  | 
 | 199 | +      const envelopeItems = eventData.envelope[1];  | 
 | 200 | +      for (const envelopeItem of envelopeItems) {  | 
 | 201 | +        if (await callback(envelopeItem)) {  | 
 | 202 | +          resolve(envelopeItem);  | 
 | 203 | +          return true;  | 
 | 204 | +        }  | 
 | 205 | +      }  | 
 | 206 | +      return false;  | 
 | 207 | +    }).catch(reject);  | 
 | 208 | +  });  | 
 | 209 | +}  | 
 | 210 | + | 
 | 211 | +export function waitForError(  | 
 | 212 | +  proxyServerName: string,  | 
 | 213 | +  callback: (transactionEvent: Event) => Promise<boolean> | boolean,  | 
 | 214 | +): Promise<Event> {  | 
 | 215 | +  return new Promise((resolve, reject) => {  | 
 | 216 | +    waitForEnvelopeItem(proxyServerName, async envelopeItem => {  | 
 | 217 | +      const [envelopeItemHeader, envelopeItemBody] = envelopeItem;  | 
 | 218 | +      if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) {  | 
 | 219 | +        resolve(envelopeItemBody as Event);  | 
 | 220 | +        return true;  | 
 | 221 | +      }  | 
 | 222 | +      return false;  | 
 | 223 | +    }).catch(reject);  | 
 | 224 | +  });  | 
 | 225 | +}  | 
 | 226 | + | 
 | 227 | +export function waitForTransaction(  | 
 | 228 | +  proxyServerName: string,  | 
 | 229 | +  callback: (transactionEvent: Event) => Promise<boolean> | boolean,  | 
 | 230 | +): Promise<Event> {  | 
 | 231 | +  return new Promise((resolve, reject) => {  | 
 | 232 | +    waitForEnvelopeItem(proxyServerName, async envelopeItem => {  | 
 | 233 | +      const [envelopeItemHeader, envelopeItemBody] = envelopeItem;  | 
 | 234 | +      if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) {  | 
 | 235 | +        resolve(envelopeItemBody as Event);  | 
 | 236 | +        return true;  | 
 | 237 | +      }  | 
 | 238 | +      return false;  | 
 | 239 | +    }).catch(reject);  | 
 | 240 | +  });  | 
 | 241 | +}  | 
 | 242 | + | 
 | 243 | +const TEMP_FILE_PREFIX = 'event-proxy-server-';  | 
 | 244 | + | 
 | 245 | +async function registerCallbackServerPort(serverName: string, port: string): Promise<void> {  | 
 | 246 | +  const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);  | 
 | 247 | +  await writeFile(tmpFilePath, port, { encoding: 'utf8' });  | 
 | 248 | +}  | 
 | 249 | + | 
 | 250 | +function retrieveCallbackServerPort(serverName: string): Promise<string> {  | 
 | 251 | +  const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);  | 
 | 252 | +  return readFile(tmpFilePath, 'utf8');  | 
 | 253 | +}  | 
0 commit comments