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));
});
}
|