• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gittrends-app / github-proxy-server / 21078838775

16 Jan 2026 07:47PM UTC coverage: 78.144% (-2.4%) from 80.499%
21078838775

push

github

hsborges
v12.0.0

133 of 184 branches covered (72.28%)

Branch coverage included in aggregate %.

246 of 301 relevant lines covered (81.73%)

93.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.02
/src/proxy-client.ts
1
/* Author: Hudson S. Borges */
2
import type { IncomingMessage, ServerResponse } from 'node:http';
3

4
import type { Dispatcher } from 'undici';
5

6
export interface ProxyClientOptions {
7
  target: string;
8
  timeout: number;
9
  dispatcher?: Dispatcher;
10
}
11

12
export class ProxyClient {
13
  private readonly target: string;
14
  private readonly timeout: number;
15
  private readonly dispatcher?: Dispatcher;
16

17
  constructor(options: ProxyClientOptions) {
18
    this.target = options.target;
177✔
19
    this.timeout = options.timeout;
177✔
20
    this.dispatcher = options.dispatcher;
177✔
21
  }
22

23
  /**
24
   * Proxy an incoming HTTP request to the target server
25
   * @param req - Incoming request from Express
26
   * @param res - Outgoing response to Express client
27
   * @param options - Additional options for request modification
28
   */
29
  async proxy(
30
    req: IncomingMessage,
31
    res: ServerResponse,
32
    options?: {
33
      modifyHeaders?: (headers: Record<string, string>) => Record<string, string>;
34
      onResponse?: (data: {
35
        status: number;
36
        statusText: string;
37
        headers: Record<string, string>;
38
      }) => void | Promise<void>;
39
    }
40
  ): Promise<void> {
41
    const controller = new AbortController();
165✔
42
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);
165✔
43

44
    try {
165✔
45
      // Build target URL
46
      const targetUrl = new URL(req.url || '/', this.target);
165!
47

48
      // Copy headers from incoming request
49
      const headers: Record<string, string> = {};
165✔
50
      for (const [key, value] of Object.entries(req.headers)) {
165✔
51
        if (value !== undefined) {
462!
52
          headers[key] = Array.isArray(value) ? value.join(', ') : value;
462✔
53
        }
54
      }
55

56
      // Remove host header to avoid conflicts
57
      delete headers.host;
165✔
58

59
      // Apply header modifications if provided
60
      const modifiedHeaders = options?.modifyHeaders ? options.modifyHeaders(headers) : headers;
165✔
61

62
      // Add forwarded headers (x-forwarded-*)
63
      const forwarded = headers['x-forwarded-for'] || req.socket.remoteAddress || '';
165!
64
      modifiedHeaders['x-forwarded-for'] = forwarded;
165✔
65
      modifiedHeaders['x-forwarded-proto'] = 'https' in req.socket ? 'https' : 'http';
165!
66
      modifiedHeaders['x-forwarded-host'] = headers.host || '';
165✔
67

68
      // Prepare request body if present
69
      let body: Buffer | undefined;
70
      if (req.method !== 'GET' && req.method !== 'HEAD') {
165✔
71
        body = await this.readRequestBody(req);
6✔
72
      }
73

74
      // Make the fetch request
75
      const response = await fetch(targetUrl.toString(), {
165✔
76
        method: req.method,
77
        headers: modifiedHeaders,
78
        body: body,
79
        signal: controller.signal,
80
        redirect: 'manual',
81
        dispatcher: this.dispatcher
82
      });
83

84
      clearTimeout(timeoutId);
160✔
85

86
      // Convert immutable response headers to mutable object
87
      const responseHeaders: Record<string, string> = {};
160✔
88
      response.headers.forEach((value, key) => {
160✔
89
        responseHeaders[key] = value;
644✔
90
      });
91

92
      // Call onResponse callback if provided (with mutable headers)
93
      if (options?.onResponse) {
160✔
94
        await options.onResponse({
144✔
95
          status: response.status,
96
          statusText: response.statusText,
97
          headers: responseHeaders
98
        });
99
      }
100

101
      // Remove content-encoding headers since fetch automatically decompresses
102
      // Keeping them would cause ERR_CONTENT_DECODING_FAILED in browsers
103
      delete responseHeaders['content-encoding'];
160✔
104
      delete responseHeaders['content-length']; // Also remove as length changes after decompression
160✔
105

106
      // Copy response status
107
      res.statusCode = response.status;
160✔
108
      res.statusMessage = response.statusText;
160✔
109

110
      // Copy modified response headers
111
      for (const [key, value] of Object.entries(responseHeaders)) {
160✔
112
        res.setHeader(key, value);
146✔
113
      }
114

115
      // Stream response body
116
      if (response.body) {
160✔
117
        const reader = response.body.getReader();
158✔
118
        try {
158✔
119
          while (true) {
158✔
120
            const { done, value } = await reader.read();
175✔
121
            if (done) break;
175✔
122
            if (!res.write(value)) {
18!
123
              // Backpressure: wait for drain event
124
              await new Promise((resolve) => res.once('drain', resolve));
×
125
            }
126
          }
127
          res.end();
157✔
128
        } catch (error) {
129
          reader.releaseLock();
1✔
130
          throw error;
1✔
131
        }
132
      } else {
133
        res.end();
2✔
134
      }
135
    } catch (error) {
136
      clearTimeout(timeoutId);
6✔
137

138
      // Convert abort errors to ETIMEDOUT
139
      if ((error as Error).name === 'AbortError' || (error as Error).name === 'TimeoutError') {
6✔
140
        const timeoutError = new Error('ETIMEDOUT') as Error & { code: string };
2✔
141
        timeoutError.code = 'ETIMEDOUT';
2✔
142
        throw timeoutError;
2✔
143
      }
144

145
      throw error;
4✔
146
    }
147
  }
148

149
  /**
150
   * Read the full request body into a buffer
151
   */
152
  private readRequestBody(req: IncomingMessage): Promise<Buffer> {
153
    return new Promise((resolve, reject) => {
6✔
154
      const chunks: Buffer[] = [];
6✔
155
      req.on('data', (chunk) => chunks.push(chunk));
6✔
156
      req.on('end', () => resolve(Buffer.concat(chunks)));
6✔
157
      req.on('error', reject);
6✔
158
    });
159
  }
160
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc