@specific.dev/spectest 0.107.1 → 0.107.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -113,9 +113,15 @@ export async function startIngressServer(options) {
113
113
  headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
114
114
  }
115
115
  const method = incoming.method ?? "GET";
116
+ // An HTTP/1 request without framing has no body. Giving it even an
117
+ // empty stream makes fetch invent chunked framing upstream (e.g. a
118
+ // bodyless DELETE then fails Fastify's content-type validation).
119
+ // Keep explicitly chunked bodies streaming, including empty ones.
120
+ const hasBody = method !== "GET" && method !== "HEAD"
121
+ && (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
116
122
  const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
117
123
  method, headers, signal: abort.signal,
118
- body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
124
+ body: hasBody ? Readable.toWeb(incoming) : undefined,
119
125
  duplex: "half",
120
126
  });
121
127
  peers.set(req, incoming.socket);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.107.1",
3
+ "version": "0.107.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,7 +8,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { execFileSync } from "node:child_process";
11
- import { forwardableHeaders, withIngressCors } from "./http-proxy";
11
+ import { forwardableHeaders, upstreamRequestHeaders, withIngressCors } from "./http-proxy";
12
12
  import { startIngressServer, type IngressServer } from "./ingress-server";
13
13
  import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
14
14
 
@@ -24,8 +24,14 @@ let release: (() => void) | undefined;
24
24
  let upstreamClosed: Promise<unknown>;
25
25
 
26
26
  beforeAll(async () => {
27
- upstream = http.createServer((req, res) => {
27
+ upstream = http.createServer(async (req, res) => {
28
28
  const path = req.url!;
29
+ if (path === "/request") {
30
+ const body = await bytes(req);
31
+ res.setHeader("content-type", "application/json");
32
+ res.end(JSON.stringify({ method: req.method, headers: req.headers, body: body.toString() }));
33
+ return;
34
+ }
29
35
  let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
30
36
  res.setHeader("content-type", "application/octet-stream");
31
37
  res.setHeader("etag", '"original"');
@@ -59,7 +65,8 @@ beforeAll(async () => {
59
65
  port: 0, hostname: "127.0.0.1",
60
66
  fetch: req => withIngressCors(req, "permissive", async current => {
61
67
  const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
62
- method: current.method, headers: current.headers, signal: current.signal,
68
+ method: current.method, headers: upstreamRequestHeaders(current.headers, { proto: "http" }),
69
+ body: current.body, signal: current.signal,
63
70
  decompress: false, keepalive: false,
64
71
  });
65
72
  return new Response(res.body, {
@@ -83,7 +90,7 @@ afterAll(() => {
83
90
  if (dir) rmSync(dir, { recursive: true, force: true });
84
91
  });
85
92
 
86
- function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string> } = {}) {
93
+ function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string>; body?: string } = {}) {
87
94
  const boundary = options.boundary ?? "https";
88
95
  return new Promise<http.IncomingMessage>((resolve, reject) => {
89
96
  const req = (boundary === "https" ? https : http).request({
@@ -93,7 +100,7 @@ function request(path: string, options: { boundary?: "storage" | "http" | "https
93
100
  }, resolve);
94
101
  req.once("error", reject);
95
102
  req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
96
- req.end();
103
+ req.end(options.body);
97
104
  });
98
105
  }
99
106
 
@@ -180,6 +187,51 @@ describe("ingress response framing", () => {
180
187
  }
181
188
  });
182
189
 
190
+ describe("ingress request framing", () => {
191
+ for (const boundary of ["storage", "http", "https"] as const) {
192
+ test(`${boundary}: unframed requests do not acquire a chunked body`, async () => {
193
+ // fetch may send Content-Length: 0 and conceal this regression. Use
194
+ // node:http methods that send neither length nor transfer encoding.
195
+ for (const method of ["DELETE", "OPTIONS"]) {
196
+ const res = await request("/request", { boundary, method });
197
+ const received = JSON.parse((await bytes(res)).toString());
198
+ expect(res.statusCode).toBe(200);
199
+ expect(received.method).toBe(method);
200
+ // Bun fetch can normalize an absent body to Content-Length: 0.
201
+ // The direct boundary proves the client itself sent no framing.
202
+ if (boundary === "storage") expect(received.headers["content-length"]).toBeUndefined();
203
+ else expect(received.headers["content-length"] ?? "0").toBe("0");
204
+ expect(received.headers["transfer-encoding"]).toBeUndefined();
205
+ expect(received.body).toBe("");
206
+ }
207
+ });
208
+
209
+ test(`${boundary}: explicit zero length stays empty`, async () => {
210
+ for (const method of ["DELETE", "OPTIONS", "POST", "PUT", "PATCH"]) {
211
+ const res = await request("/request", { boundary, method, headers: { "content-length": "0" } });
212
+ const received = JSON.parse((await bytes(res)).toString());
213
+ expect(received.headers["content-length"]).toBe("0");
214
+ expect(received.headers["transfer-encoding"]).toBeUndefined();
215
+ expect(received.body).toBe("");
216
+ }
217
+ });
218
+
219
+ test(`${boundary}: framed bodies survive, including empty chunked requests`, async () => {
220
+ for (const body of ["", "request payload"]) {
221
+ for (const chunked of [false, true]) {
222
+ const res = await request("/request", { boundary, method: "DELETE", body,
223
+ headers: chunked ? { "transfer-encoding": "chunked" } : { "content-length": String(Buffer.byteLength(body)) },
224
+ });
225
+ const received = JSON.parse((await bytes(res)).toString());
226
+ expect(received.headers["content-length"]).toBe(chunked ? undefined : String(Buffer.byteLength(body)));
227
+ expect(received.headers["transfer-encoding"]).toBe(chunked ? "chunked" : undefined);
228
+ expect(received.body).toBe(body);
229
+ }
230
+ }
231
+ });
232
+ }
233
+ });
234
+
183
235
  describe("ingress request lifecycle", () => {
184
236
  test("streams uploads and preserves the request URL, headers and peer", async () => {
185
237
  const server = await startIngressServer({
@@ -131,9 +131,15 @@ export async function startIngressServer(options: {
131
131
  headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
132
132
  }
133
133
  const method = incoming.method ?? "GET";
134
+ // An HTTP/1 request without framing has no body. Giving it even an
135
+ // empty stream makes fetch invent chunked framing upstream (e.g. a
136
+ // bodyless DELETE then fails Fastify's content-type validation).
137
+ // Keep explicitly chunked bodies streaming, including empty ones.
138
+ const hasBody = method !== "GET" && method !== "HEAD"
139
+ && (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
134
140
  const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
135
141
  method, headers, signal: abort.signal,
136
- body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
142
+ body: hasBody ? Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array> : undefined,
137
143
  duplex: "half",
138
144
  } as RequestInit);
139
145
  peers.set(req, incoming.socket);