File size: 6,192 Bytes
eb3f11e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/**
 * Raw-socket HTTP request driver for webhook tests.
 *
 * `fetch` cannot express a server that answers while the sender is still uploading and
 * then closes: undici surfaces the failed upload and discards the response it already
 * received. Body-limit rejections are exactly that shape, so asserting them needs a
 * client that reports the status line and the connection outcome independently.
 */
import net from "node:net";

export type RawHttpResult = {
  /** First line of the response, or an empty string when the server sent nothing. */
  statusLine: string;
  /** Lowercase response header names and their final values. */
  headers: Record<string, string>;
  /** Response body after the header block. */
  body: string;
  /** True when the server closed the connection rather than leaving it open. */
  closedByServer: boolean;
};

/**
 * Reassembles a chunked response body so callers can assert the payload the server sent.
 *
 * Chunk sizes count bytes, so the scan stays on the Buffer: decoding first would make a
 * multi-byte character consume one slice position and misplace every later boundary.
 */
function decodeChunkedBody(raw: Buffer): Buffer {
  const parts: Buffer[] = [];
  let offset = 0;
  for (;;) {
    const lineEnd = raw.indexOf("\r\n", offset, "latin1");
    if (lineEnd === -1) {
      break;
    }
    const size = Number.parseInt(raw.toString("latin1", offset, lineEnd).trim(), 16);
    if (!Number.isFinite(size) || size <= 0) {
      break;
    }
    parts.push(raw.subarray(lineEnd + 2, lineEnd + 2 + size));
    offset = lineEnd + 2 + size + 2;
  }
  return Buffer.concat(parts);
}

export async function postRawWebhook(params: {
  /** Absolute URL of the webhook endpoint. */
  url: string;
  /** Request body. */
  body: string;
  headers?: Record<string, string>;
  /** How long to keep the socket open before reporting it as retained. */
  idleTimeoutMs?: number;
  /**
   * Send the body incrementally instead of in one write, so the server decides while the
   * upload is still active rather than from the declared length alone.
   */
  chunk?: { bytes: number; intervalMs: number };
  /** Content-Length to declare; defaults to the body's real length. */
  contentLength?: number;
  /**
   * Send with chunked transfer encoding and no Content-Length, so a size limit can only be
   * detected from the bytes that actually arrive rather than from a declared length.
   */
  chunkedEncoding?: boolean;
}): Promise<RawHttpResult> {
  const target = new URL(params.url);
  const port = Number(target.port);
  const payload = Buffer.from(params.body, "utf-8");
  const idleTimeoutMs = params.idleTimeoutMs ?? 2_000;
  const headerLines = Object.entries(params.headers ?? {})
    .map(([name, value]) => `${name}: ${value}\r\n`)
    .join("");
  const head =
    `POST ${target.pathname}${target.search} HTTP/1.1\r\n` +
    `Host: ${target.hostname}:${port}\r\n` +
    headerLines +
    (params.chunkedEncoding
      ? `Transfer-Encoding: chunked\r\n\r\n`
      : `Content-Length: ${params.contentLength ?? payload.length}\r\n\r\n`);

  return await new Promise<RawHttpResult>((resolve) => {
    const socket = net.connect(port, target.hostname);
    const received: Buffer[] = [];
    let settled = false;
    let timer: ReturnType<typeof setTimeout> | undefined;

    const settle = (closedByServer: boolean) => {
      if (settled) {
        return;
      }
      settled = true;
      if (timer) {
        clearTimeout(timer);
      }
      socket.destroy();
      const raw = Buffer.concat(received);
      const headerEnd = raw.indexOf("\r\n\r\n", 0, "latin1");
      const headBlock =
        headerEnd === -1 ? raw.toString("latin1") : raw.toString("latin1", 0, headerEnd);
      const rawBody = headerEnd === -1 ? Buffer.alloc(0) : raw.subarray(headerEnd + 4);
      const chunked = /transfer-encoding:\s*chunked/i.test(headBlock);
      const headers = Object.fromEntries(
        headBlock
          .split("\r\n")
          .slice(1)
          .map((line) => {
            const separator = line.indexOf(":");
            return [line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim()];
          }),
      );
      resolve({
        statusLine: (headBlock.split("\r\n")[0] ?? "").trim(),
        headers,
        body: (chunked ? decodeChunkedBody(rawBody) : rawBody).toString("utf-8"),
        closedByServer,
      });
    };

    // Chunk framing is ASCII and the body is raw bytes. Writing them together through a
    // string would re-encode every byte above 0x7f after its length was already declared.
    const writeBody = (slice: Buffer) => {
      if (!params.chunkedEncoding) {
        socket.write(slice);
        return;
      }
      socket.write(`${slice.length.toString(16)}\r\n`);
      socket.write(slice);
      socket.write("\r\n");
    };

    socket.on("connect", () => {
      socket.write(head);
      if (!params.chunk) {
        writeBody(payload);
        if (params.chunkedEncoding) {
          socket.write("0\r\n\r\n");
        }
      } else {
        const { bytes, intervalMs } = params.chunk;
        let offset = 0;
        const pump = () => {
          if (settled || socket.destroyed) {
            return;
          }
          if (offset >= payload.length) {
            if (params.chunkedEncoding) {
              socket.write(`0\r\n\r\n`);
            }
            return;
          }
          const end = Math.min(offset + bytes, payload.length);
          writeBody(payload.subarray(offset, end));
          offset = end;
          setTimeout(pump, intervalMs).unref?.();
        };
        pump();
      }
      // Only the idle timeout reports a retained connection; a server that answers and
      // closes always reaches "close" below.
      timer = setTimeout(() => settle(false), idleTimeoutMs);
      timer.unref?.();
    });
    socket.on("data", (chunk: Buffer) => {
      received.push(chunk);
    });
    // Rejecting mid-upload makes the write fail on this side; the response may already be
    // buffered, so wait for "close" rather than settling on the write error.
    socket.on("error", () => {});
    socket.on("close", () => settle(true));
  });
}