Skip to content

Commit 4fe2c67

Browse files
committed
Update PR #2742
1 parent 9cb9390 commit 4fe2c67

37 files changed

Lines changed: 2070 additions & 1660 deletions

File tree

‎dev-packages/seinfeld/src/cassette.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ export interface RecordedRequest {
4646
/** A single recorded response, as it appeared on the wire. */
4747
export interface RecordedResponse {
4848
/** Original wire chunk sizes and inter-chunk delays for interactive streams. */
49-
streamChunks?: Array<{ size: number; delayMs: number }>;
49+
streamChunks?: Array<{
50+
size: number;
51+
delayMs: number;
52+
/** Complete Connect request frames received before this response chunk. */
53+
requestFrames?: number;
54+
}>;
5055
status: number;
5156
statusText?: string;
5257
headers: Record<string, string>;

‎dev-packages/seinfeld/src/format/v1.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const recordedResponseSchema = z.object({
3434
z.object({
3535
size: z.number().int().positive(),
3636
delayMs: z.number().nonnegative(),
37+
requestFrames: z.number().int().nonnegative().optional(),
3738
}),
3839
)
3940
.optional(),

‎dev-packages/seinfeld/src/http.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ export async function recordResponseDraft(
107107
/** Build a Fetch API `Response` from a recorded response body. */
108108
export async function buildResponse(
109109
recorded: RecordedResponse | RecordedResponseOrDraft,
110-
ctx?: { store: CassetteStore; name: string },
110+
ctx?: {
111+
store: CassetteStore;
112+
name: string;
113+
waitForRequestFrames?: (count: number) => Promise<void>;
114+
},
111115
): Promise<Response> {
112116
// Expand \n-joined set-cookie back into multiple header entries.
113117
// Strip encoding headers: stored bytes are already decoded by the fetch
@@ -150,9 +154,9 @@ export async function buildResponse(
150154
: await decodeBody(recorded.body, ctx);
151155
if (bytes.length === 0) return new Response(null, init);
152156
if (recorded.streamChunks) {
153-
// Interactive providers wait for client tool results between chunks.
154-
// Preserve the recorded pacing instead of closing the response while
155-
// the SDK still has pending writes to its request stream.
157+
// Interactive providers wait for client messages between chunks. Frame
158+
// counts preserve that ordering even if replay's client is slower than
159+
// recording's client; recorded delays alone cannot enforce it.
156160
const chunks = recorded.streamChunks;
157161
let index = 0;
158162
let offset = 0;
@@ -162,7 +166,13 @@ export async function buildResponse(
162166
controller.close();
163167
return;
164168
}
165-
const { size, delayMs } = chunks[index++]!;
169+
const { size, delayMs, requestFrames } = chunks[index++]!;
170+
if (requestFrames !== undefined) {
171+
if (!ctx?.waitForRequestFrames) {
172+
throw new Error("Connect replay requires request frame tracking");
173+
}
174+
await ctx.waitForRequestFrames(requestFrames);
175+
}
166176
await new Promise((resolve) => setTimeout(resolve, delayMs));
167177
controller.enqueue(bytes.slice(offset, offset + size));
168178
offset += size;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { IncomingMessage } from "node:http";
2+
3+
/** Count complete Connect envelopes without retaining their payloads. */
4+
export function trackConnectRequest(req: IncomingMessage) {
5+
const header = Buffer.alloc(5);
6+
let headerSize = 0;
7+
let remaining = 0;
8+
let frames = 0;
9+
let closed = false;
10+
let notify = () => {};
11+
12+
const onData = (data: Buffer) => {
13+
let offset = 0;
14+
while (offset < data.length) {
15+
if (headerSize < header.length) {
16+
const size = Math.min(header.length - headerSize, data.length - offset);
17+
data.copy(header, headerSize, offset, offset + size);
18+
headerSize += size;
19+
offset += size;
20+
if (headerSize < header.length) continue;
21+
remaining = header.readUInt32BE(1);
22+
}
23+
const size = Math.min(remaining, data.length - offset);
24+
remaining -= size;
25+
offset += size;
26+
if (remaining === 0) {
27+
frames++;
28+
headerSize = 0;
29+
}
30+
}
31+
notify();
32+
};
33+
const onClose = () => {
34+
closed = true;
35+
notify();
36+
};
37+
req.on("data", onData);
38+
req.on("end", onClose);
39+
req.on("close", onClose);
40+
req.on("error", onClose);
41+
42+
return {
43+
get frames() {
44+
return frames;
45+
},
46+
// Response stream pulls are sequential, so there is only one waiter.
47+
async waitFor(count: number) {
48+
while (frames < count) {
49+
if (closed) {
50+
throw new Error(
51+
`Connect request ended after ${frames} frames; replay requires ${count}`,
52+
);
53+
}
54+
await new Promise<void>((resolve) => {
55+
notify = resolve;
56+
});
57+
}
58+
},
59+
dispose() {
60+
req.off("data", onData);
61+
req.off("end", onClose);
62+
req.off("close", onClose);
63+
req.off("error", onClose);
64+
onClose();
65+
},
66+
};
67+
}

‎dev-packages/seinfeld/src/recorder.ts‎

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import type {
1717
CassetteEntry,
1818
CassetteMode,
1919
RecordedRequest,
20+
RecordedResponse,
2021
} from "./cassette";
22+
import { trackConnectRequest } from "./internal/connect-request";
2123
import { AggregateCassetteMissError, CassetteMissError } from "./errors";
2224
import { applyFilters } from "./normalizer";
2325
import type { FilterSpec } from "./normalizer";
@@ -46,7 +48,11 @@ export type RequestUrlMatcher = string | RegExp;
4648
export type IgnoredRequestMatcher = RequestUrlMatcher;
4749
export type StreamingRequestMatcher =
4850
| RequestUrlMatcher
49-
| { url: RequestUrlMatcher; httpVersion: "2" };
51+
| {
52+
url: RequestUrlMatcher;
53+
httpVersion?: "1.1" | "2";
54+
requestFraming?: "connect";
55+
};
5056

5157
// ---- Internal draft types ------------------------------------------------
5258

@@ -126,18 +132,32 @@ async function handleReplayWithoutRequestBody(
126132
req: IncomingMessage,
127133
res: ServerResponse,
128134
upstreamUrl: string,
135+
requestFraming?: "connect",
129136
): Promise<void> {
130137
// Some duplex APIs keep the request body open while waiting for response
131138
// messages. For opted-in URLs, replay can respond immediately because the
132139
// cassette match is configured to ignore the request body.
140+
const tracker =
141+
requestFraming === "connect" ? trackConnectRequest(req) : undefined;
142+
const onClose = () => tracker?.dispose();
143+
res.on("close", onClose);
133144
req.resume();
134-
const response = await handleReplay(ctx, {
135-
method: req.method ?? "GET",
136-
url: upstreamUrl,
137-
headers: headersToRecord(req.headers),
138-
body: { kind: "empty" },
139-
});
140-
await writeFetchResponse(res, response);
145+
try {
146+
const response = await handleReplay(
147+
ctx,
148+
{
149+
method: req.method ?? "GET",
150+
url: upstreamUrl,
151+
headers: headersToRecord(req.headers),
152+
body: { kind: "empty" },
153+
},
154+
tracker?.waitFor,
155+
);
156+
await writeFetchResponse(res, response);
157+
} finally {
158+
res.off("close", onClose);
159+
tracker?.dispose();
160+
}
141161
}
142162

143163
async function handleStreamingRecord(
@@ -146,10 +166,13 @@ async function handleStreamingRecord(
146166
res: ServerResponse,
147167
upstreamUrl: string,
148168
httpVersion: "1.1" | "2",
169+
requestFraming?: "connect",
149170
): Promise<void> {
150171
const upstream = new URL(upstreamUrl);
151172
const responseChunks: Buffer[] = [];
152-
const streamChunks: Array<{ size: number; delayMs: number }> = [];
173+
const streamChunks: NonNullable<RecordedResponse["streamChunks"]> = [];
174+
const tracker =
175+
requestFraming === "connect" ? trackConnectRequest(req) : undefined;
153176
let lastChunkAt = performance.now();
154177
let responseHeaders: IncomingHttpHeaders = {};
155178
let responseStatus = 502;
@@ -202,6 +225,7 @@ async function handleStreamingRecord(
202225
streamChunks.push({
203226
size: bytes.length,
204227
delayMs: Math.round(now - lastChunkAt),
228+
...(tracker ? { requestFrames: tracker.frames } : {}),
205229
});
206230
lastChunkAt = now;
207231
responseChunks.push(bytes);
@@ -239,6 +263,7 @@ async function handleStreamingRecord(
239263
upstreamUrl,
240264
});
241265
} finally {
266+
tracker?.dispose();
242267
session?.destroy();
243268
}
244269
}
@@ -249,7 +274,7 @@ function pushStreamingEntry(
249274
requestHeaders: Record<string, number | string | string[] | undefined>;
250275
requestMethod: string;
251276
responseBody: Uint8Array;
252-
streamChunks: Array<{ size: number; delayMs: number }>;
277+
streamChunks: NonNullable<RecordedResponse["streamChunks"]>;
253278
responseHeaders: Record<string, number | string | string[] | undefined>;
254279
responseStatus: number;
255280
responseStatusText?: string;
@@ -284,6 +309,7 @@ function pushStreamingEntry(
284309
function handleReplay(
285310
ctx: CassetteContext,
286311
recorded: RecordedRequest,
312+
waitForRequestFrames?: (count: number) => Promise<void>,
287313
): Promise<Response> {
288314
const filtered = asNormalized(applyFilters(recorded, ctx.filters));
289315
const matchKey = filtered.matchKey;
@@ -310,7 +336,11 @@ function handleReplay(
310336
);
311337
}
312338

313-
return buildResponse(match.response, { store: ctx.store, name: ctx.name });
339+
return buildResponse(match.response, {
340+
store: ctx.store,
341+
name: ctx.name,
342+
waitForRequestFrames,
343+
});
314344
}
315345

316346
async function handleRecord(
@@ -575,8 +605,18 @@ export function createCassetteServer(
575605
),
576606
);
577607
if (streamingRequest) {
608+
const options =
609+
typeof streamingRequest === "object" && "url" in streamingRequest
610+
? streamingRequest
611+
: undefined;
578612
if (ctx.mode === "replay") {
579-
await handleReplayWithoutRequestBody(ctx, req, res, upstreamUrl);
613+
await handleReplayWithoutRequestBody(
614+
ctx,
615+
req,
616+
res,
617+
upstreamUrl,
618+
options?.requestFraming,
619+
);
580620
return;
581621
}
582622
if (ctx.mode === "record") {
@@ -585,10 +625,8 @@ export function createCassetteServer(
585625
req,
586626
res,
587627
upstreamUrl,
588-
typeof streamingRequest === "object" &&
589-
"httpVersion" in streamingRequest
590-
? streamingRequest.httpVersion
591-
: "1.1",
628+
options?.httpVersion ?? "1.1",
629+
options?.requestFraming,
592630
);
593631
return;
594632
}

0 commit comments

Comments
 (0)