@copilotkit/aimock 1.46.0 → 1.47.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -1
- package/README.md +4 -1
- package/dist/aws-event-stream.cjs +20 -23
- package/dist/aws-event-stream.cjs.map +1 -1
- package/dist/aws-event-stream.d.cts +0 -1
- package/dist/aws-event-stream.d.ts +0 -1
- package/dist/aws-event-stream.js +20 -24
- package/dist/aws-event-stream.js.map +1 -1
- package/dist/bedrock-converse.cjs +79 -82
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +80 -83
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +112 -121
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +113 -122
- package/dist/bedrock.js.map +1 -1
- package/dist/cohere.cjs +58 -106
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +60 -108
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.d.cts.map +1 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/fixture-loader.cjs +103 -2
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +104 -4
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-interactions.cjs +63 -121
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +65 -123
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +242 -155
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +244 -157
- package/dist/gemini.js.map +1 -1
- package/dist/helpers.cjs +400 -7
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +51 -1
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +51 -1
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +391 -8
- package/dist/helpers.js.map +1 -1
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp-fakes.d.cts.map +1 -1
- package/dist/messages.cjs +159 -141
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +161 -143
- package/dist/messages.js.map +1 -1
- package/dist/misbehavior.cjs +62 -3
- package/dist/misbehavior.cjs.map +1 -1
- package/dist/misbehavior.d.cts.map +1 -1
- package/dist/misbehavior.d.ts.map +1 -1
- package/dist/misbehavior.js +63 -4
- package/dist/misbehavior.js.map +1 -1
- package/dist/ndjson-writer.cjs +21 -23
- package/dist/ndjson-writer.cjs.map +1 -1
- package/dist/ndjson-writer.d.cts +0 -1
- package/dist/ndjson-writer.d.cts.map +1 -1
- package/dist/ndjson-writer.d.ts +0 -1
- package/dist/ndjson-writer.d.ts.map +1 -1
- package/dist/ndjson-writer.js +21 -24
- package/dist/ndjson-writer.js.map +1 -1
- package/dist/ollama.cjs +153 -147
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.d.cts.map +1 -1
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +155 -149
- package/dist/ollama.js.map +1 -1
- package/dist/openrouter-chat.cjs +1 -1
- package/dist/openrouter-chat.js +1 -1
- package/dist/raw-validation.cjs +177 -0
- package/dist/raw-validation.cjs.map +1 -0
- package/dist/raw-validation.js +176 -0
- package/dist/raw-validation.js.map +1 -0
- package/dist/response/deliver.cjs +144 -0
- package/dist/response/deliver.cjs.map +1 -0
- package/dist/response/deliver.js +143 -0
- package/dist/response/deliver.js.map +1 -0
- package/dist/response/frame-loop.cjs +102 -0
- package/dist/response/frame-loop.cjs.map +1 -0
- package/dist/response/frame-loop.js +100 -0
- package/dist/response/frame-loop.js.map +1 -0
- package/dist/response/lifecycle.cjs +223 -0
- package/dist/response/lifecycle.cjs.map +1 -0
- package/dist/response/lifecycle.js +211 -0
- package/dist/response/lifecycle.js.map +1 -0
- package/dist/response/proxy.cjs +71 -0
- package/dist/response/proxy.cjs.map +1 -0
- package/dist/response/proxy.js +70 -0
- package/dist/response/proxy.js.map +1 -0
- package/dist/response/raw.cjs +228 -0
- package/dist/response/raw.cjs.map +1 -0
- package/dist/response/raw.js +227 -0
- package/dist/response/raw.js.map +1 -0
- package/dist/response/timing.cjs +157 -0
- package/dist/response/timing.cjs.map +1 -0
- package/dist/response/timing.js +150 -0
- package/dist/response/timing.js.map +1 -0
- package/dist/responses.cjs +269 -148
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.d.cts.map +1 -1
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +270 -149
- package/dist/responses.js.map +1 -1
- package/dist/router.cjs +20 -4
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts +7 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts +7 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +21 -5
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +89 -120
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -122
- package/dist/server.js.map +1 -1
- package/dist/sse-writer.cjs +49 -30
- package/dist/sse-writer.cjs.map +1 -1
- package/dist/sse-writer.d.cts +0 -1
- package/dist/sse-writer.d.cts.map +1 -1
- package/dist/sse-writer.d.ts +0 -1
- package/dist/sse-writer.d.ts.map +1 -1
- package/dist/sse-writer.js +49 -31
- package/dist/sse-writer.js.map +1 -1
- package/dist/transcription.cjs +19 -33
- package/dist/transcription.cjs.map +1 -1
- package/dist/transcription.d.cts.map +1 -1
- package/dist/transcription.d.ts.map +1 -1
- package/dist/transcription.js +20 -34
- package/dist/transcription.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +173 -10
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +173 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ws-framing.cjs +12 -6
- package/dist/ws-framing.cjs.map +1 -1
- package/dist/ws-framing.d.cts +2 -0
- package/dist/ws-framing.d.cts.map +1 -1
- package/dist/ws-framing.d.ts +2 -0
- package/dist/ws-framing.d.ts.map +1 -1
- package/dist/ws-framing.js +12 -6
- package/dist/ws-framing.js.map +1 -1
- package/dist/ws-gemini-live.cjs +120 -93
- package/dist/ws-gemini-live.cjs.map +1 -1
- package/dist/ws-gemini-live.js +120 -93
- package/dist/ws-gemini-live.js.map +1 -1
- package/dist/ws-realtime.cjs +80 -70
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +80 -70
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +72 -53
- package/dist/ws-responses.cjs.map +1 -1
- package/dist/ws-responses.d.cts.map +1 -1
- package/dist/ws-responses.d.ts.map +1 -1
- package/dist/ws-responses.js +73 -54
- package/dist/ws-responses.js.map +1 -1
- package/package.json +1 -1
- package/skills/write-fixtures/SKILL.md +34 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { httpSink, runFrameLoop } from "./frame-loop.js";
|
|
2
|
+
import { delay } from "../sse-writer.js";
|
|
3
|
+
import { responseGoneReason } from "../chaos.js";
|
|
4
|
+
import { AIMOCK_DISCONNECTED, closedByClient, httpLifecycle, markInterrupted, noteDeliveryStart, requestSocket, statusLineSent, teardownReason } from "./lifecycle.js";
|
|
5
|
+
import { extendRecordedTimings, nonStreamingDelayMs, nonStreamingRecordedTimings, nonStreamingTimingEnabled } from "./timing.js";
|
|
6
|
+
|
|
7
|
+
//#region src/response/deliver.ts
|
|
8
|
+
/**
|
|
9
|
+
* Journal a delivery that ended early. The reason is always set: the
|
|
10
|
+
* lifecycle's first cause when it fired, else {@link teardownReason} for the
|
|
11
|
+
* response that is gone. When no byte of the response reached the socket
|
|
12
|
+
* ({@link statusLineSent}), the status is 0, the rule the server and the
|
|
13
|
+
* chaos `disconnect` apply to a torn socket. A fixture interruption
|
|
14
|
+
* (`disconnectAfterMs`, `truncateAfterChunks`) keeps the status it was
|
|
15
|
+
* journaled with, as base recorded it.
|
|
16
|
+
*/
|
|
17
|
+
function journalTeardown(entry, res, lifecycle) {
|
|
18
|
+
markInterrupted(entry, lifecycle?.reason() ?? teardownReason(res));
|
|
19
|
+
if (lifecycle?.cause() !== "fixture" && !statusLineSent(res)) entry.response.status = 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the response was torn down: the lifecycle fired, the response is
|
|
23
|
+
* destroyed or chaos disconnected it, or the client closed the socket.
|
|
24
|
+
*/
|
|
25
|
+
function tornDown(res, lifecycle) {
|
|
26
|
+
const socket = requestSocket(res);
|
|
27
|
+
const gone = responseGoneReason(res);
|
|
28
|
+
return lifecycle?.reason() !== void 0 || gone === "client-gone" || gone === "chaos-disconnected" || !res.writableEnded && !!socket && closedByClient(socket);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Journal a response that something else on aimock's side already answered
|
|
32
|
+
* (head committed or body ended) as aimock's teardown, with the status the
|
|
33
|
+
* client actually got.
|
|
34
|
+
*/
|
|
35
|
+
function journalAnsweredElsewhere(entry, res) {
|
|
36
|
+
markInterrupted(entry, AIMOCK_DISCONNECTED);
|
|
37
|
+
entry.response.status = statusLineSent(res) ? res.statusCode : 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether `deliverJson` can no longer write its reply, journaling why when it
|
|
41
|
+
* cannot. Torn down ({@link tornDown}): {@link journalTeardown}. Already
|
|
42
|
+
* answered by something else on aimock's side (head committed or body
|
|
43
|
+
* ended): writing again would throw `ERR_HTTP_HEADERS_SENT`, so it is
|
|
44
|
+
* journaled by {@link journalAnsweredElsewhere}, and a body that never ended
|
|
45
|
+
* is destroyed so the client does not hang.
|
|
46
|
+
*/
|
|
47
|
+
function journalUnwritable(entry, res, lifecycle) {
|
|
48
|
+
if (tornDown(res, lifecycle)) {
|
|
49
|
+
journalTeardown(entry, res, lifecycle);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (res.headersSent || res.writableEnded) {
|
|
53
|
+
journalAnsweredElsewhere(entry, res);
|
|
54
|
+
if (!res.writableEnded) res.destroy();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Deliver one streamed reply and journal how it ended. Builds the HTTP
|
|
61
|
+
* lifecycle (client close + fixture interruption) and runs the frame loop on
|
|
62
|
+
* it. When the loop stops early it destroys `res` (if it has not ended) and
|
|
63
|
+
* marks the entry with the first cause's reason (or, when no cause fired, who
|
|
64
|
+
* tore the response down). A response other code already answered (head
|
|
65
|
+
* committed before the stream, or body ended) is journaled as
|
|
66
|
+
* {@link deliverJson} journals it. It disposes the lifecycle on every path.
|
|
67
|
+
*/
|
|
68
|
+
async function deliverStream(res, entry, fixture, codec, frames, timing, opts) {
|
|
69
|
+
noteDeliveryStart(res);
|
|
70
|
+
const headSentBefore = res.headersSent;
|
|
71
|
+
const lifecycle = httpLifecycle(res, fixture, { streaming: true });
|
|
72
|
+
try {
|
|
73
|
+
if (!await runFrameLoop(httpSink(res), frames, codec, timing, {
|
|
74
|
+
lifecycle,
|
|
75
|
+
onChunkSent: lifecycle.tick,
|
|
76
|
+
status: opts?.status,
|
|
77
|
+
headers: opts?.headers
|
|
78
|
+
})) {
|
|
79
|
+
const answeredElsewhere = (headSentBefore || res.writableEnded) && !tornDown(res, lifecycle);
|
|
80
|
+
if (!res.writableEnded) res.destroy();
|
|
81
|
+
if (answeredElsewhere) journalAnsweredElsewhere(entry, res);
|
|
82
|
+
else journalTeardown(entry, res, lifecycle);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
lifecycle.dispose();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Deliver one non-streaming JSON reply. The body is a thunk.
|
|
90
|
+
*
|
|
91
|
+
* On both paths it first checks {@link journalUnwritable}, before any wait. A
|
|
92
|
+
* response that can no longer take the reply (torn down, or already answered)
|
|
93
|
+
* is not written, the thunk is never called, and the entry is marked
|
|
94
|
+
* interrupted. An already-answered response whose body never ended is
|
|
95
|
+
* destroyed, so the client does not hang.
|
|
96
|
+
*
|
|
97
|
+
* With non-streaming timing off (the default) it then runs
|
|
98
|
+
* `JSON.stringify(body())`, then `writeHead`, then `end`: the body is built
|
|
99
|
+
* before anything is written, so a builder that throws leaves the response
|
|
100
|
+
* unwritten.
|
|
101
|
+
*
|
|
102
|
+
* With timing on, it waits the non-streaming delay on the client's close
|
|
103
|
+
* signal, checks {@link journalUnwritable} again (the response can be torn
|
|
104
|
+
* down or answered during the wait), then runs the thunk, so a `created`
|
|
105
|
+
* timestamp reflects the write time.
|
|
106
|
+
*
|
|
107
|
+
* Rejects with a `RangeError` when `chunkSize` is a number below 1: that is
|
|
108
|
+
* a programming error, and the delay math divides by it. No user input reaches
|
|
109
|
+
* it, because every caller clamps with `Math.max(1, ...)`. A user value that
|
|
110
|
+
* is not a number clamps to `NaN`: the reply is still served, with a zero
|
|
111
|
+
* wait when timing is on.
|
|
112
|
+
*/
|
|
113
|
+
async function deliverJson(res, entry, fixture, body, opts) {
|
|
114
|
+
if (opts.chunkSize < 1) throw new RangeError(`deliverJson: chunkSize must be >= 1, got ${opts.chunkSize}`);
|
|
115
|
+
const write = () => {
|
|
116
|
+
const text = JSON.stringify(body());
|
|
117
|
+
res.writeHead(opts.status ?? 200, {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
...opts.headers
|
|
120
|
+
});
|
|
121
|
+
res.end(text);
|
|
122
|
+
};
|
|
123
|
+
noteDeliveryStart(res);
|
|
124
|
+
if (journalUnwritable(entry, res, void 0)) return;
|
|
125
|
+
if (!nonStreamingTimingEnabled(fixture, opts.surface)) {
|
|
126
|
+
write();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const lifecycle = httpLifecycle(res, fixture, { streaming: false });
|
|
130
|
+
try {
|
|
131
|
+
const fixtureTimings = nonStreamingRecordedTimings(fixture, opts.surface, opts.recordedTimings);
|
|
132
|
+
const recordedTimings = opts.duplicateId ? extendRecordedTimings(fixtureTimings, Math.max(1, Math.ceil(opts.payloadLength / opts.chunkSize)), true) : fixtureTimings;
|
|
133
|
+
await delay(nonStreamingDelayMs(fixture, opts.payloadLength, opts.chunkSize, recordedTimings, opts.replaySpeed), lifecycle.signal);
|
|
134
|
+
if (journalUnwritable(entry, res, lifecycle)) return;
|
|
135
|
+
write();
|
|
136
|
+
} finally {
|
|
137
|
+
lifecycle.dispose();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
export { deliverJson, deliverStream };
|
|
143
|
+
//# sourceMappingURL=deliver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deliver.js","names":[],"sources":["../../src/response/deliver.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { Fixture, JournalEntry, RecordedTimings } from \"../types.js\";\nimport { delay } from \"../sse-writer.js\";\nimport { httpSink, runFrameLoop, type FrameCodec, type FrameTiming } from \"./frame-loop.js\";\nimport { responseGoneReason } from \"../chaos.js\";\nimport {\n AIMOCK_DISCONNECTED,\n closedByClient,\n httpLifecycle,\n markInterrupted,\n noteDeliveryStart,\n statusLineSent,\n teardownReason,\n requestSocket,\n type DeliveryLifecycle,\n} from \"./lifecycle.js\";\nimport {\n extendRecordedTimings,\n nonStreamingDelayMs,\n nonStreamingRecordedTimings,\n nonStreamingTimingEnabled,\n type NonStreamingSurface,\n} from \"./timing.js\";\n\n/** The lifecycle fields the teardown journal reads. */\ntype TeardownSource = Pick<DeliveryLifecycle, \"cause\" | \"reason\">;\n\n/**\n * Journal a delivery that ended early. The reason is always set: the\n * lifecycle's first cause when it fired, else {@link teardownReason} for the\n * response that is gone. When no byte of the response reached the socket\n * ({@link statusLineSent}), the status is 0, the rule the server and the\n * chaos `disconnect` apply to a torn socket. A fixture interruption\n * (`disconnectAfterMs`, `truncateAfterChunks`) keeps the status it was\n * journaled with, as base recorded it.\n */\nfunction journalTeardown(\n entry: JournalEntry,\n res: http.ServerResponse,\n lifecycle: TeardownSource | undefined,\n): void {\n markInterrupted(entry, lifecycle?.reason() ?? teardownReason(res));\n if (lifecycle?.cause() !== \"fixture\" && !statusLineSent(res)) entry.response.status = 0;\n}\n\n/**\n * Whether the response was torn down: the lifecycle fired, the response is\n * destroyed or chaos disconnected it, or the client closed the socket.\n */\nfunction tornDown(res: http.ServerResponse, lifecycle: TeardownSource | undefined): boolean {\n const socket = requestSocket(res);\n const gone = responseGoneReason(res);\n return (\n lifecycle?.reason() !== undefined ||\n gone === \"client-gone\" ||\n gone === \"chaos-disconnected\" ||\n (!res.writableEnded && !!socket && closedByClient(socket))\n );\n}\n\n/**\n * Journal a response that something else on aimock's side already answered\n * (head committed or body ended) as aimock's teardown, with the status the\n * client actually got.\n */\nfunction journalAnsweredElsewhere(entry: JournalEntry, res: http.ServerResponse): void {\n markInterrupted(entry, AIMOCK_DISCONNECTED);\n entry.response.status = statusLineSent(res) ? res.statusCode : 0;\n}\n\n/**\n * Whether `deliverJson` can no longer write its reply, journaling why when it\n * cannot. Torn down ({@link tornDown}): {@link journalTeardown}. Already\n * answered by something else on aimock's side (head committed or body\n * ended): writing again would throw `ERR_HTTP_HEADERS_SENT`, so it is\n * journaled by {@link journalAnsweredElsewhere}, and a body that never ended\n * is destroyed so the client does not hang.\n */\nfunction journalUnwritable(\n entry: JournalEntry,\n res: http.ServerResponse,\n lifecycle: TeardownSource | undefined,\n): boolean {\n if (tornDown(res, lifecycle)) {\n journalTeardown(entry, res, lifecycle);\n return true;\n }\n if (res.headersSent || res.writableEnded) {\n journalAnsweredElsewhere(entry, res);\n if (!res.writableEnded) res.destroy();\n return true;\n }\n return false;\n}\n\n/**\n * Deliver one streamed reply and journal how it ended. Builds the HTTP\n * lifecycle (client close + fixture interruption) and runs the frame loop on\n * it. When the loop stops early it destroys `res` (if it has not ended) and\n * marks the entry with the first cause's reason (or, when no cause fired, who\n * tore the response down). A response other code already answered (head\n * committed before the stream, or body ended) is journaled as\n * {@link deliverJson} journals it. It disposes the lifecycle on every path.\n */\nexport async function deliverStream<F>(\n res: http.ServerResponse,\n entry: JournalEntry,\n fixture: Fixture,\n codec: FrameCodec<F>,\n frames: readonly F[],\n timing: FrameTiming,\n opts?: {\n status?: number; // When set, sent with `writeHead` before the first frame (issue #549).\n headers?: Record<string, string>; // Merged over the codec's headers (issue #549).\n },\n): Promise<void> {\n noteDeliveryStart(res);\n const headSentBefore = res.headersSent;\n const lifecycle = httpLifecycle(res, fixture, { streaming: true });\n try {\n const completed = await runFrameLoop(httpSink(res), frames, codec, timing, {\n lifecycle,\n onChunkSent: lifecycle.tick,\n status: opts?.status,\n headers: opts?.headers,\n });\n if (!completed) {\n // Read before the destroy below, which makes any response look torn down.\n const answeredElsewhere = (headSentBefore || res.writableEnded) && !tornDown(res, lifecycle);\n if (!res.writableEnded) res.destroy();\n if (answeredElsewhere) journalAnsweredElsewhere(entry, res);\n else journalTeardown(entry, res, lifecycle);\n }\n } finally {\n lifecycle.dispose();\n }\n}\n\nexport interface DeliverJsonOptions {\n /**\n * Passed to `nonStreamingTimingEnabled` and `nonStreamingRecordedTimings`:\n * OpenAI chat, or any other provider.\n */\n surface: NonStreamingSurface;\n status?: number; // The response status, 200 when unset (issue #549).\n headers?: Record<string, string>; // Merged over the JSON `Content-Type` header (issue #549).\n /** The served payload length the wait is sized from. */\n payloadLength: number;\n /**\n * The wait is sized in chunks of this many characters. Callers pass\n * `Math.max(1, <fixture or server chunkSize>)`: 1 or more, or `NaN` for a\n * non-numeric value. `NaN` gives a zero wait.\n */\n chunkSize: number;\n recordedTimings?: RecordedTimings;\n /** A K4 duplicate-id plan: extend the recorded gaps before sizing the wait. */\n duplicateId?: boolean;\n replaySpeed?: number;\n}\n\n/**\n * Deliver one non-streaming JSON reply. The body is a thunk.\n *\n * On both paths it first checks {@link journalUnwritable}, before any wait. A\n * response that can no longer take the reply (torn down, or already answered)\n * is not written, the thunk is never called, and the entry is marked\n * interrupted. An already-answered response whose body never ended is\n * destroyed, so the client does not hang.\n *\n * With non-streaming timing off (the default) it then runs\n * `JSON.stringify(body())`, then `writeHead`, then `end`: the body is built\n * before anything is written, so a builder that throws leaves the response\n * unwritten.\n *\n * With timing on, it waits the non-streaming delay on the client's close\n * signal, checks {@link journalUnwritable} again (the response can be torn\n * down or answered during the wait), then runs the thunk, so a `created`\n * timestamp reflects the write time.\n *\n * Rejects with a `RangeError` when `chunkSize` is a number below 1: that is\n * a programming error, and the delay math divides by it. No user input reaches\n * it, because every caller clamps with `Math.max(1, ...)`. A user value that\n * is not a number clamps to `NaN`: the reply is still served, with a zero\n * wait when timing is on.\n */\nexport async function deliverJson(\n res: http.ServerResponse,\n entry: JournalEntry,\n fixture: Fixture,\n body: () => unknown,\n opts: DeliverJsonOptions,\n): Promise<void> {\n if (opts.chunkSize < 1) {\n throw new RangeError(`deliverJson: chunkSize must be >= 1, got ${opts.chunkSize}`);\n }\n const write = (): void => {\n const text = JSON.stringify(body());\n res.writeHead(opts.status ?? 200, { \"Content-Type\": \"application/json\", ...opts.headers });\n res.end(text);\n };\n noteDeliveryStart(res);\n // Checked before the lifecycle and the wait on both paths: a response that\n // cannot take the reply is journaled at once, with no wait.\n if (journalUnwritable(entry, res, undefined)) return;\n if (!nonStreamingTimingEnabled(fixture, opts.surface)) {\n write();\n return;\n }\n const lifecycle = httpLifecycle(res, fixture, { streaming: false });\n try {\n const fixtureTimings = nonStreamingRecordedTimings(fixture, opts.surface, opts.recordedTimings);\n const recordedTimings = opts.duplicateId\n ? extendRecordedTimings(\n fixtureTimings,\n Math.max(1, Math.ceil(opts.payloadLength / opts.chunkSize)),\n true,\n )\n : fixtureTimings;\n await delay(\n nonStreamingDelayMs(\n fixture,\n opts.payloadLength,\n opts.chunkSize,\n recordedTimings,\n opts.replaySpeed,\n ),\n lifecycle.signal,\n );\n if (journalUnwritable(entry, res, lifecycle)) return;\n write();\n } finally {\n lifecycle.dispose();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoCA,SAAS,gBACP,OACA,KACA,WACM;AACN,iBAAgB,OAAO,WAAW,QAAQ,IAAI,eAAe,IAAI,CAAC;AAClE,KAAI,WAAW,OAAO,KAAK,aAAa,CAAC,eAAe,IAAI,CAAE,OAAM,SAAS,SAAS;;;;;;AAOxF,SAAS,SAAS,KAA0B,WAAgD;CAC1F,MAAM,SAAS,cAAc,IAAI;CACjC,MAAM,OAAO,mBAAmB,IAAI;AACpC,QACE,WAAW,QAAQ,KAAK,UACxB,SAAS,iBACT,SAAS,wBACR,CAAC,IAAI,iBAAiB,CAAC,CAAC,UAAU,eAAe,OAAO;;;;;;;AAS7D,SAAS,yBAAyB,OAAqB,KAAgC;AACrF,iBAAgB,OAAO,oBAAoB;AAC3C,OAAM,SAAS,SAAS,eAAe,IAAI,GAAG,IAAI,aAAa;;;;;;;;;;AAWjE,SAAS,kBACP,OACA,KACA,WACS;AACT,KAAI,SAAS,KAAK,UAAU,EAAE;AAC5B,kBAAgB,OAAO,KAAK,UAAU;AACtC,SAAO;;AAET,KAAI,IAAI,eAAe,IAAI,eAAe;AACxC,2BAAyB,OAAO,IAAI;AACpC,MAAI,CAAC,IAAI,cAAe,KAAI,SAAS;AACrC,SAAO;;AAET,QAAO;;;;;;;;;;;AAYT,eAAsB,cACpB,KACA,OACA,SACA,OACA,QACA,QACA,MAIe;AACf,mBAAkB,IAAI;CACtB,MAAM,iBAAiB,IAAI;CAC3B,MAAM,YAAY,cAAc,KAAK,SAAS,EAAE,WAAW,MAAM,CAAC;AAClE,KAAI;AAOF,MAAI,CANc,MAAM,aAAa,SAAS,IAAI,EAAE,QAAQ,OAAO,QAAQ;GACzE;GACA,aAAa,UAAU;GACvB,QAAQ,MAAM;GACd,SAAS,MAAM;GAChB,CAAC,EACc;GAEd,MAAM,qBAAqB,kBAAkB,IAAI,kBAAkB,CAAC,SAAS,KAAK,UAAU;AAC5F,OAAI,CAAC,IAAI,cAAe,KAAI,SAAS;AACrC,OAAI,kBAAmB,0BAAyB,OAAO,IAAI;OACtD,iBAAgB,OAAO,KAAK,UAAU;;WAErC;AACR,YAAU,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDvB,eAAsB,YACpB,KACA,OACA,SACA,MACA,MACe;AACf,KAAI,KAAK,YAAY,EACnB,OAAM,IAAI,WAAW,4CAA4C,KAAK,YAAY;CAEpF,MAAM,cAAoB;EACxB,MAAM,OAAO,KAAK,UAAU,MAAM,CAAC;AACnC,MAAI,UAAU,KAAK,UAAU,KAAK;GAAE,gBAAgB;GAAoB,GAAG,KAAK;GAAS,CAAC;AAC1F,MAAI,IAAI,KAAK;;AAEf,mBAAkB,IAAI;AAGtB,KAAI,kBAAkB,OAAO,KAAK,OAAU,CAAE;AAC9C,KAAI,CAAC,0BAA0B,SAAS,KAAK,QAAQ,EAAE;AACrD,SAAO;AACP;;CAEF,MAAM,YAAY,cAAc,KAAK,SAAS,EAAE,WAAW,OAAO,CAAC;AACnE,KAAI;EACF,MAAM,iBAAiB,4BAA4B,SAAS,KAAK,SAAS,KAAK,gBAAgB;EAC/F,MAAM,kBAAkB,KAAK,cACzB,sBACE,gBACA,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,gBAAgB,KAAK,UAAU,CAAC,EAC3D,KACD,GACD;AACJ,QAAM,MACJ,oBACE,SACA,KAAK,eACL,KAAK,WACL,iBACA,KAAK,YACN,EACD,UAAU,OACX;AACD,MAAI,kBAAkB,OAAO,KAAK,UAAU,CAAE;AAC9C,SAAO;WACC;AACR,YAAU,SAAS"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const require_sse_writer = require('../sse-writer.cjs');
|
|
2
|
+
|
|
3
|
+
//#region src/response/frame-loop.ts
|
|
4
|
+
/** An HTTP response as a frame sink. Gone means destroyed. */
|
|
5
|
+
function httpSink(res) {
|
|
6
|
+
return {
|
|
7
|
+
gone: () => res.destroyed,
|
|
8
|
+
writableEnded: () => res.writableEnded,
|
|
9
|
+
headersSent: () => res.headersSent,
|
|
10
|
+
write: (chunk) => {
|
|
11
|
+
res.write(chunk);
|
|
12
|
+
},
|
|
13
|
+
end: () => {
|
|
14
|
+
res.end();
|
|
15
|
+
},
|
|
16
|
+
setHeader: (name, value) => {
|
|
17
|
+
res.setHeader(name, value);
|
|
18
|
+
},
|
|
19
|
+
writeHead: (status, headers) => {
|
|
20
|
+
res.writeHead(status, headers);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** A WebSocket connection as a frame sink. Gone means closed; it never ends. */
|
|
25
|
+
function wsSink(ws) {
|
|
26
|
+
return {
|
|
27
|
+
gone: () => ws.isClosed,
|
|
28
|
+
writableEnded: () => false,
|
|
29
|
+
write: (chunk) => {
|
|
30
|
+
ws.send(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
31
|
+
},
|
|
32
|
+
end: () => {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The shared streaming loop. Per frame, in order: gone check (with
|
|
37
|
+
* `checkGoneBeforeDelay`) → `calculateDelay` → `delay(d, signal)` → abort
|
|
38
|
+
* check → gone check (with `checkGoneBeforeDelay`) → `writableEnded` (return
|
|
39
|
+
* `true`) → write → advance the index and tick (counting frames only) → abort
|
|
40
|
+
* re-check. After the last frame the trailer and `end()` run if the response
|
|
41
|
+
* has not ended. Returns `false` when stopped by an abort, or by a gone sink
|
|
42
|
+
* (with a lifecycle or `checkGoneBeforeDelay`), including an abort before the
|
|
43
|
+
* first frame or with no frames, and `true` otherwise. Rejects with a
|
|
44
|
+
* `TypeError` when given both a lifecycle and a signal, or a status for a sink
|
|
45
|
+
* without `writeHead`.
|
|
46
|
+
*
|
|
47
|
+
* With a lifecycle, a response that other code already answered is not a
|
|
48
|
+
* completed delivery: an ended response (at entry or at the `writableEnded`
|
|
49
|
+
* check) returns `false`, and so does a head already committed at entry,
|
|
50
|
+
* which `setHeader`/`writeHead` would reject with `ERR_HTTP_HEADERS_SENT`.
|
|
51
|
+
* Without a lifecycle (the exported writers) an ended response still returns
|
|
52
|
+
* `true`, as base did.
|
|
53
|
+
*/
|
|
54
|
+
async function runFrameLoop(sink, frames, codec, timing, opts = {}) {
|
|
55
|
+
const { lifecycle } = opts;
|
|
56
|
+
if (lifecycle !== void 0 && opts.signal !== void 0) throw new TypeError("runFrameLoop: pass a lifecycle or a signal, not both");
|
|
57
|
+
const signal = lifecycle?.signal ?? opts.signal;
|
|
58
|
+
const stopped = () => signal?.aborted === true || lifecycle !== void 0 && sink.gone();
|
|
59
|
+
const endedIsCompleted = lifecycle === void 0;
|
|
60
|
+
if (sink.writableEnded()) return endedIsCompleted;
|
|
61
|
+
if (lifecycle && sink.gone()) return false;
|
|
62
|
+
if (lifecycle && sink.headersSent?.() === true) return false;
|
|
63
|
+
const headers = opts.headers === void 0 ? codec.headers : {
|
|
64
|
+
...codec.headers,
|
|
65
|
+
...opts.headers
|
|
66
|
+
};
|
|
67
|
+
if (opts.status !== void 0 || codec.writeHeadImmediately) {
|
|
68
|
+
if (!sink.writeHead) throw new TypeError("runFrameLoop: a status was requested but the sink has no writeHead");
|
|
69
|
+
sink.writeHead(opts.status ?? 200, headers);
|
|
70
|
+
} else if (sink.setHeader) for (const [name, value] of Object.entries(headers)) sink.setHeader(name, value);
|
|
71
|
+
if (codec.prelude && !sink.writableEnded()) {
|
|
72
|
+
const prelude = codec.prelude();
|
|
73
|
+
if (prelude !== void 0) sink.write(prelude);
|
|
74
|
+
}
|
|
75
|
+
let chunkIndex = 0;
|
|
76
|
+
for (const frame of frames) {
|
|
77
|
+
if (codec.checkGoneBeforeDelay && sink.gone()) return false;
|
|
78
|
+
const chunkDelay = require_sse_writer.calculateDelay(chunkIndex, timing.streamingProfile, timing.latency ?? 0, timing.recordedTimings, timing.replaySpeed);
|
|
79
|
+
if (chunkDelay > 0) await require_sse_writer.delay(chunkDelay, signal);
|
|
80
|
+
if (stopped()) return false;
|
|
81
|
+
if (codec.checkGoneBeforeDelay && sink.gone()) return false;
|
|
82
|
+
if (sink.writableEnded()) return endedIsCompleted;
|
|
83
|
+
sink.write(codec.encode(frame));
|
|
84
|
+
if (codec.counts?.(frame) !== false) {
|
|
85
|
+
chunkIndex++;
|
|
86
|
+
opts.onChunkSent?.();
|
|
87
|
+
}
|
|
88
|
+
if (stopped()) return false;
|
|
89
|
+
}
|
|
90
|
+
if (stopped()) return false;
|
|
91
|
+
if (!sink.writableEnded()) {
|
|
92
|
+
for (const part of codec.trailer?.(false) ?? []) sink.write(part);
|
|
93
|
+
sink.end();
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
exports.httpSink = httpSink;
|
|
100
|
+
exports.runFrameLoop = runFrameLoop;
|
|
101
|
+
exports.wsSink = wsSink;
|
|
102
|
+
//# sourceMappingURL=frame-loop.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame-loop.cjs","names":["calculateDelay","delay"],"sources":["../../src/response/frame-loop.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { RecordedTimings, StreamingProfile } from \"../types.js\";\nimport type { WebSocketConnection } from \"../ws-framing.js\";\nimport { calculateDelay, delay } from \"../sse-writer.js\";\nimport type { DeliveryLifecycle } from \"./lifecycle.js\";\n\n/**\n * How one wire frames a stream. The frame loop is the same for every wire;\n * only these parts differ.\n */\nexport interface FrameCodec<F> {\n /** Response headers, set before the first write (skipped if the response already ended). */\n headers: Record<string, string>;\n /** Written once before the first frame, when it returns a value (the OpenRouter keepalive in `sseCodec`). */\n prelude?: () => string | Buffer | undefined;\n /** The bytes for one frame. */\n encode(frame: F): string | Buffer;\n /**\n * Whether a frame advances the chunk index and the fixture tick (default\n * true). A frame that does not count still pays the delay at the current\n * index.\n */\n counts?(frame: F): boolean;\n /**\n * Written after the last frame, before `end()`, when the response has not\n * ended. Only a completed delivery reaches it: an aborted one returns `false`\n * with no trailer and no `end()`, so `aborted` is always `false` here.\n */\n trailer?(aborted: boolean): Array<string | Buffer>;\n /** Send the head with `writeHead` up front instead of `setHeader`. */\n writeHeadImmediately?: boolean;\n /**\n * Check `sink.gone()` before each delay as well as after it, with or\n * without a lifecycle, and return `false` when gone.\n */\n checkGoneBeforeDelay?: boolean;\n}\n\n/**\n * Where frames go: an HTTP response or a WebSocket connection. A sink with no\n * HTTP head (WebSocket) omits `setHeader` and `writeHead`; asking such a sink\n * for a status (`status` or `writeHeadImmediately`) is a programming error.\n */\nexport interface FrameSink {\n gone(): boolean;\n writableEnded(): boolean;\n write(chunk: string | Buffer): void;\n end(): void;\n /** Whether the head was already committed (HTTP only). */\n headersSent?(): boolean;\n setHeader?(name: string, value: string): void;\n writeHead?(status: number, headers: Record<string, string>): void;\n}\n\n/** Per-frame delay inputs, passed straight to `calculateDelay`. */\nexport interface FrameTiming {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n}\n\ninterface FrameLoopCommonOptions {\n onChunkSent?: () => void;\n /** When given, `writeHead(status, headers)` is sent before the first frame. */\n status?: number;\n /** Extra headers, merged over the codec's headers. */\n headers?: Record<string, string>;\n}\n\n/**\n * The stop source is a `lifecycle` or a bare `signal`, never both: passing both\n * is a programming error and rejects with a `TypeError`.\n */\nexport type FrameLoopOptions = FrameLoopCommonOptions &\n (\n | {\n /**\n * Its signal stops the loop and `sink.gone()` is checked next to every\n * abort check.\n */\n lifecycle: DeliveryLifecycle;\n signal?: never;\n }\n | {\n lifecycle?: never;\n /**\n * Without a lifecycle, `signal` stops the loop, and a gone sink stops it\n * only when the codec sets `checkGoneBeforeDelay`.\n */\n signal?: AbortSignal;\n }\n );\n\n/** An HTTP response as a frame sink. Gone means destroyed. */\nexport function httpSink(res: http.ServerResponse): FrameSink {\n return {\n gone: () => res.destroyed,\n writableEnded: () => res.writableEnded,\n headersSent: () => res.headersSent,\n write: (chunk) => {\n res.write(chunk);\n },\n end: () => {\n res.end();\n },\n setHeader: (name, value) => {\n res.setHeader(name, value);\n },\n writeHead: (status, headers) => {\n res.writeHead(status, headers);\n },\n };\n}\n\n/** A WebSocket connection as a frame sink. Gone means closed; it never ends. */\nexport function wsSink(ws: WebSocketConnection): FrameSink {\n return {\n gone: () => ws.isClosed,\n writableEnded: () => false,\n write: (chunk) => {\n ws.send(typeof chunk === \"string\" ? chunk : chunk.toString(\"utf8\"));\n },\n end: () => {},\n };\n}\n\n/**\n * The shared streaming loop. Per frame, in order: gone check (with\n * `checkGoneBeforeDelay`) → `calculateDelay` → `delay(d, signal)` → abort\n * check → gone check (with `checkGoneBeforeDelay`) → `writableEnded` (return\n * `true`) → write → advance the index and tick (counting frames only) → abort\n * re-check. After the last frame the trailer and `end()` run if the response\n * has not ended. Returns `false` when stopped by an abort, or by a gone sink\n * (with a lifecycle or `checkGoneBeforeDelay`), including an abort before the\n * first frame or with no frames, and `true` otherwise. Rejects with a\n * `TypeError` when given both a lifecycle and a signal, or a status for a sink\n * without `writeHead`.\n *\n * With a lifecycle, a response that other code already answered is not a\n * completed delivery: an ended response (at entry or at the `writableEnded`\n * check) returns `false`, and so does a head already committed at entry,\n * which `setHeader`/`writeHead` would reject with `ERR_HTTP_HEADERS_SENT`.\n * Without a lifecycle (the exported writers) an ended response still returns\n * `true`, as base did.\n */\nexport async function runFrameLoop<F>(\n sink: FrameSink,\n frames: readonly F[],\n codec: FrameCodec<F>,\n timing: FrameTiming,\n opts: FrameLoopOptions = {},\n): Promise<boolean> {\n const { lifecycle } = opts;\n if (lifecycle !== undefined && opts.signal !== undefined) {\n throw new TypeError(\"runFrameLoop: pass a lifecycle or a signal, not both\");\n }\n const signal = lifecycle?.signal ?? opts.signal;\n const stopped = (): boolean =>\n signal?.aborted === true || (lifecycle !== undefined && sink.gone());\n\n // With a lifecycle, a response that other code answered is not a completed delivery.\n const endedIsCompleted = lifecycle === undefined;\n if (sink.writableEnded()) return endedIsCompleted;\n if (lifecycle && sink.gone()) return false;\n if (lifecycle && sink.headersSent?.() === true) return false;\n\n const headers =\n opts.headers === undefined ? codec.headers : { ...codec.headers, ...opts.headers };\n if (opts.status !== undefined || codec.writeHeadImmediately) {\n if (!sink.writeHead) {\n throw new TypeError(\"runFrameLoop: a status was requested but the sink has no writeHead\");\n }\n sink.writeHead(opts.status ?? 200, headers);\n } else if (sink.setHeader) {\n for (const [name, value] of Object.entries(headers)) sink.setHeader(name, value);\n }\n\n if (codec.prelude && !sink.writableEnded()) {\n const prelude = codec.prelude();\n if (prelude !== undefined) sink.write(prelude);\n }\n\n let chunkIndex = 0;\n for (const frame of frames) {\n if (codec.checkGoneBeforeDelay && sink.gone()) return false;\n const chunkDelay = calculateDelay(\n chunkIndex,\n timing.streamingProfile,\n timing.latency ?? 0,\n timing.recordedTimings,\n timing.replaySpeed,\n );\n if (chunkDelay > 0) await delay(chunkDelay, signal);\n if (stopped()) return false;\n if (codec.checkGoneBeforeDelay && sink.gone()) return false;\n if (sink.writableEnded()) return endedIsCompleted;\n sink.write(codec.encode(frame));\n if (codec.counts?.(frame) !== false) {\n chunkIndex++;\n opts.onChunkSent?.();\n }\n if (stopped()) return false;\n }\n\n // An abort the loop never observed (no frames) still stops the delivery.\n if (stopped()) return false;\n if (!sink.writableEnded()) {\n for (const part of codec.trailer?.(false) ?? []) sink.write(part);\n sink.end();\n }\n return true;\n}\n"],"mappings":";;;;AA+FA,SAAgB,SAAS,KAAqC;AAC5D,QAAO;EACL,YAAY,IAAI;EAChB,qBAAqB,IAAI;EACzB,mBAAmB,IAAI;EACvB,QAAQ,UAAU;AAChB,OAAI,MAAM,MAAM;;EAElB,WAAW;AACT,OAAI,KAAK;;EAEX,YAAY,MAAM,UAAU;AAC1B,OAAI,UAAU,MAAM,MAAM;;EAE5B,YAAY,QAAQ,YAAY;AAC9B,OAAI,UAAU,QAAQ,QAAQ;;EAEjC;;;AAIH,SAAgB,OAAO,IAAoC;AACzD,QAAO;EACL,YAAY,GAAG;EACf,qBAAqB;EACrB,QAAQ,UAAU;AAChB,MAAG,KAAK,OAAO,UAAU,WAAW,QAAQ,MAAM,SAAS,OAAO,CAAC;;EAErE,WAAW;EACZ;;;;;;;;;;;;;;;;;;;;;AAsBH,eAAsB,aACpB,MACA,QACA,OACA,QACA,OAAyB,EAAE,EACT;CAClB,MAAM,EAAE,cAAc;AACtB,KAAI,cAAc,UAAa,KAAK,WAAW,OAC7C,OAAM,IAAI,UAAU,uDAAuD;CAE7E,MAAM,SAAS,WAAW,UAAU,KAAK;CACzC,MAAM,gBACJ,QAAQ,YAAY,QAAS,cAAc,UAAa,KAAK,MAAM;CAGrE,MAAM,mBAAmB,cAAc;AACvC,KAAI,KAAK,eAAe,CAAE,QAAO;AACjC,KAAI,aAAa,KAAK,MAAM,CAAE,QAAO;AACrC,KAAI,aAAa,KAAK,eAAe,KAAK,KAAM,QAAO;CAEvD,MAAM,UACJ,KAAK,YAAY,SAAY,MAAM,UAAU;EAAE,GAAG,MAAM;EAAS,GAAG,KAAK;EAAS;AACpF,KAAI,KAAK,WAAW,UAAa,MAAM,sBAAsB;AAC3D,MAAI,CAAC,KAAK,UACR,OAAM,IAAI,UAAU,qEAAqE;AAE3F,OAAK,UAAU,KAAK,UAAU,KAAK,QAAQ;YAClC,KAAK,UACd,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,CAAE,MAAK,UAAU,MAAM,MAAM;AAGlF,KAAI,MAAM,WAAW,CAAC,KAAK,eAAe,EAAE;EAC1C,MAAM,UAAU,MAAM,SAAS;AAC/B,MAAI,YAAY,OAAW,MAAK,MAAM,QAAQ;;CAGhD,IAAI,aAAa;AACjB,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,MAAM,wBAAwB,KAAK,MAAM,CAAE,QAAO;EACtD,MAAM,aAAaA,kCACjB,YACA,OAAO,kBACP,OAAO,WAAW,GAClB,OAAO,iBACP,OAAO,YACR;AACD,MAAI,aAAa,EAAG,OAAMC,yBAAM,YAAY,OAAO;AACnD,MAAI,SAAS,CAAE,QAAO;AACtB,MAAI,MAAM,wBAAwB,KAAK,MAAM,CAAE,QAAO;AACtD,MAAI,KAAK,eAAe,CAAE,QAAO;AACjC,OAAK,MAAM,MAAM,OAAO,MAAM,CAAC;AAC/B,MAAI,MAAM,SAAS,MAAM,KAAK,OAAO;AACnC;AACA,QAAK,eAAe;;AAEtB,MAAI,SAAS,CAAE,QAAO;;AAIxB,KAAI,SAAS,CAAE,QAAO;AACtB,KAAI,CAAC,KAAK,eAAe,EAAE;AACzB,OAAK,MAAM,QAAQ,MAAM,UAAU,MAAM,IAAI,EAAE,CAAE,MAAK,MAAM,KAAK;AACjE,OAAK,KAAK;;AAEZ,QAAO"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { calculateDelay, delay } from "../sse-writer.js";
|
|
2
|
+
|
|
3
|
+
//#region src/response/frame-loop.ts
|
|
4
|
+
/** An HTTP response as a frame sink. Gone means destroyed. */
|
|
5
|
+
function httpSink(res) {
|
|
6
|
+
return {
|
|
7
|
+
gone: () => res.destroyed,
|
|
8
|
+
writableEnded: () => res.writableEnded,
|
|
9
|
+
headersSent: () => res.headersSent,
|
|
10
|
+
write: (chunk) => {
|
|
11
|
+
res.write(chunk);
|
|
12
|
+
},
|
|
13
|
+
end: () => {
|
|
14
|
+
res.end();
|
|
15
|
+
},
|
|
16
|
+
setHeader: (name, value) => {
|
|
17
|
+
res.setHeader(name, value);
|
|
18
|
+
},
|
|
19
|
+
writeHead: (status, headers) => {
|
|
20
|
+
res.writeHead(status, headers);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** A WebSocket connection as a frame sink. Gone means closed; it never ends. */
|
|
25
|
+
function wsSink(ws) {
|
|
26
|
+
return {
|
|
27
|
+
gone: () => ws.isClosed,
|
|
28
|
+
writableEnded: () => false,
|
|
29
|
+
write: (chunk) => {
|
|
30
|
+
ws.send(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
31
|
+
},
|
|
32
|
+
end: () => {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The shared streaming loop. Per frame, in order: gone check (with
|
|
37
|
+
* `checkGoneBeforeDelay`) → `calculateDelay` → `delay(d, signal)` → abort
|
|
38
|
+
* check → gone check (with `checkGoneBeforeDelay`) → `writableEnded` (return
|
|
39
|
+
* `true`) → write → advance the index and tick (counting frames only) → abort
|
|
40
|
+
* re-check. After the last frame the trailer and `end()` run if the response
|
|
41
|
+
* has not ended. Returns `false` when stopped by an abort, or by a gone sink
|
|
42
|
+
* (with a lifecycle or `checkGoneBeforeDelay`), including an abort before the
|
|
43
|
+
* first frame or with no frames, and `true` otherwise. Rejects with a
|
|
44
|
+
* `TypeError` when given both a lifecycle and a signal, or a status for a sink
|
|
45
|
+
* without `writeHead`.
|
|
46
|
+
*
|
|
47
|
+
* With a lifecycle, a response that other code already answered is not a
|
|
48
|
+
* completed delivery: an ended response (at entry or at the `writableEnded`
|
|
49
|
+
* check) returns `false`, and so does a head already committed at entry,
|
|
50
|
+
* which `setHeader`/`writeHead` would reject with `ERR_HTTP_HEADERS_SENT`.
|
|
51
|
+
* Without a lifecycle (the exported writers) an ended response still returns
|
|
52
|
+
* `true`, as base did.
|
|
53
|
+
*/
|
|
54
|
+
async function runFrameLoop(sink, frames, codec, timing, opts = {}) {
|
|
55
|
+
const { lifecycle } = opts;
|
|
56
|
+
if (lifecycle !== void 0 && opts.signal !== void 0) throw new TypeError("runFrameLoop: pass a lifecycle or a signal, not both");
|
|
57
|
+
const signal = lifecycle?.signal ?? opts.signal;
|
|
58
|
+
const stopped = () => signal?.aborted === true || lifecycle !== void 0 && sink.gone();
|
|
59
|
+
const endedIsCompleted = lifecycle === void 0;
|
|
60
|
+
if (sink.writableEnded()) return endedIsCompleted;
|
|
61
|
+
if (lifecycle && sink.gone()) return false;
|
|
62
|
+
if (lifecycle && sink.headersSent?.() === true) return false;
|
|
63
|
+
const headers = opts.headers === void 0 ? codec.headers : {
|
|
64
|
+
...codec.headers,
|
|
65
|
+
...opts.headers
|
|
66
|
+
};
|
|
67
|
+
if (opts.status !== void 0 || codec.writeHeadImmediately) {
|
|
68
|
+
if (!sink.writeHead) throw new TypeError("runFrameLoop: a status was requested but the sink has no writeHead");
|
|
69
|
+
sink.writeHead(opts.status ?? 200, headers);
|
|
70
|
+
} else if (sink.setHeader) for (const [name, value] of Object.entries(headers)) sink.setHeader(name, value);
|
|
71
|
+
if (codec.prelude && !sink.writableEnded()) {
|
|
72
|
+
const prelude = codec.prelude();
|
|
73
|
+
if (prelude !== void 0) sink.write(prelude);
|
|
74
|
+
}
|
|
75
|
+
let chunkIndex = 0;
|
|
76
|
+
for (const frame of frames) {
|
|
77
|
+
if (codec.checkGoneBeforeDelay && sink.gone()) return false;
|
|
78
|
+
const chunkDelay = calculateDelay(chunkIndex, timing.streamingProfile, timing.latency ?? 0, timing.recordedTimings, timing.replaySpeed);
|
|
79
|
+
if (chunkDelay > 0) await delay(chunkDelay, signal);
|
|
80
|
+
if (stopped()) return false;
|
|
81
|
+
if (codec.checkGoneBeforeDelay && sink.gone()) return false;
|
|
82
|
+
if (sink.writableEnded()) return endedIsCompleted;
|
|
83
|
+
sink.write(codec.encode(frame));
|
|
84
|
+
if (codec.counts?.(frame) !== false) {
|
|
85
|
+
chunkIndex++;
|
|
86
|
+
opts.onChunkSent?.();
|
|
87
|
+
}
|
|
88
|
+
if (stopped()) return false;
|
|
89
|
+
}
|
|
90
|
+
if (stopped()) return false;
|
|
91
|
+
if (!sink.writableEnded()) {
|
|
92
|
+
for (const part of codec.trailer?.(false) ?? []) sink.write(part);
|
|
93
|
+
sink.end();
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
export { httpSink, runFrameLoop, wsSink };
|
|
100
|
+
//# sourceMappingURL=frame-loop.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame-loop.js","names":[],"sources":["../../src/response/frame-loop.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { RecordedTimings, StreamingProfile } from \"../types.js\";\nimport type { WebSocketConnection } from \"../ws-framing.js\";\nimport { calculateDelay, delay } from \"../sse-writer.js\";\nimport type { DeliveryLifecycle } from \"./lifecycle.js\";\n\n/**\n * How one wire frames a stream. The frame loop is the same for every wire;\n * only these parts differ.\n */\nexport interface FrameCodec<F> {\n /** Response headers, set before the first write (skipped if the response already ended). */\n headers: Record<string, string>;\n /** Written once before the first frame, when it returns a value (the OpenRouter keepalive in `sseCodec`). */\n prelude?: () => string | Buffer | undefined;\n /** The bytes for one frame. */\n encode(frame: F): string | Buffer;\n /**\n * Whether a frame advances the chunk index and the fixture tick (default\n * true). A frame that does not count still pays the delay at the current\n * index.\n */\n counts?(frame: F): boolean;\n /**\n * Written after the last frame, before `end()`, when the response has not\n * ended. Only a completed delivery reaches it: an aborted one returns `false`\n * with no trailer and no `end()`, so `aborted` is always `false` here.\n */\n trailer?(aborted: boolean): Array<string | Buffer>;\n /** Send the head with `writeHead` up front instead of `setHeader`. */\n writeHeadImmediately?: boolean;\n /**\n * Check `sink.gone()` before each delay as well as after it, with or\n * without a lifecycle, and return `false` when gone.\n */\n checkGoneBeforeDelay?: boolean;\n}\n\n/**\n * Where frames go: an HTTP response or a WebSocket connection. A sink with no\n * HTTP head (WebSocket) omits `setHeader` and `writeHead`; asking such a sink\n * for a status (`status` or `writeHeadImmediately`) is a programming error.\n */\nexport interface FrameSink {\n gone(): boolean;\n writableEnded(): boolean;\n write(chunk: string | Buffer): void;\n end(): void;\n /** Whether the head was already committed (HTTP only). */\n headersSent?(): boolean;\n setHeader?(name: string, value: string): void;\n writeHead?(status: number, headers: Record<string, string>): void;\n}\n\n/** Per-frame delay inputs, passed straight to `calculateDelay`. */\nexport interface FrameTiming {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n}\n\ninterface FrameLoopCommonOptions {\n onChunkSent?: () => void;\n /** When given, `writeHead(status, headers)` is sent before the first frame. */\n status?: number;\n /** Extra headers, merged over the codec's headers. */\n headers?: Record<string, string>;\n}\n\n/**\n * The stop source is a `lifecycle` or a bare `signal`, never both: passing both\n * is a programming error and rejects with a `TypeError`.\n */\nexport type FrameLoopOptions = FrameLoopCommonOptions &\n (\n | {\n /**\n * Its signal stops the loop and `sink.gone()` is checked next to every\n * abort check.\n */\n lifecycle: DeliveryLifecycle;\n signal?: never;\n }\n | {\n lifecycle?: never;\n /**\n * Without a lifecycle, `signal` stops the loop, and a gone sink stops it\n * only when the codec sets `checkGoneBeforeDelay`.\n */\n signal?: AbortSignal;\n }\n );\n\n/** An HTTP response as a frame sink. Gone means destroyed. */\nexport function httpSink(res: http.ServerResponse): FrameSink {\n return {\n gone: () => res.destroyed,\n writableEnded: () => res.writableEnded,\n headersSent: () => res.headersSent,\n write: (chunk) => {\n res.write(chunk);\n },\n end: () => {\n res.end();\n },\n setHeader: (name, value) => {\n res.setHeader(name, value);\n },\n writeHead: (status, headers) => {\n res.writeHead(status, headers);\n },\n };\n}\n\n/** A WebSocket connection as a frame sink. Gone means closed; it never ends. */\nexport function wsSink(ws: WebSocketConnection): FrameSink {\n return {\n gone: () => ws.isClosed,\n writableEnded: () => false,\n write: (chunk) => {\n ws.send(typeof chunk === \"string\" ? chunk : chunk.toString(\"utf8\"));\n },\n end: () => {},\n };\n}\n\n/**\n * The shared streaming loop. Per frame, in order: gone check (with\n * `checkGoneBeforeDelay`) → `calculateDelay` → `delay(d, signal)` → abort\n * check → gone check (with `checkGoneBeforeDelay`) → `writableEnded` (return\n * `true`) → write → advance the index and tick (counting frames only) → abort\n * re-check. After the last frame the trailer and `end()` run if the response\n * has not ended. Returns `false` when stopped by an abort, or by a gone sink\n * (with a lifecycle or `checkGoneBeforeDelay`), including an abort before the\n * first frame or with no frames, and `true` otherwise. Rejects with a\n * `TypeError` when given both a lifecycle and a signal, or a status for a sink\n * without `writeHead`.\n *\n * With a lifecycle, a response that other code already answered is not a\n * completed delivery: an ended response (at entry or at the `writableEnded`\n * check) returns `false`, and so does a head already committed at entry,\n * which `setHeader`/`writeHead` would reject with `ERR_HTTP_HEADERS_SENT`.\n * Without a lifecycle (the exported writers) an ended response still returns\n * `true`, as base did.\n */\nexport async function runFrameLoop<F>(\n sink: FrameSink,\n frames: readonly F[],\n codec: FrameCodec<F>,\n timing: FrameTiming,\n opts: FrameLoopOptions = {},\n): Promise<boolean> {\n const { lifecycle } = opts;\n if (lifecycle !== undefined && opts.signal !== undefined) {\n throw new TypeError(\"runFrameLoop: pass a lifecycle or a signal, not both\");\n }\n const signal = lifecycle?.signal ?? opts.signal;\n const stopped = (): boolean =>\n signal?.aborted === true || (lifecycle !== undefined && sink.gone());\n\n // With a lifecycle, a response that other code answered is not a completed delivery.\n const endedIsCompleted = lifecycle === undefined;\n if (sink.writableEnded()) return endedIsCompleted;\n if (lifecycle && sink.gone()) return false;\n if (lifecycle && sink.headersSent?.() === true) return false;\n\n const headers =\n opts.headers === undefined ? codec.headers : { ...codec.headers, ...opts.headers };\n if (opts.status !== undefined || codec.writeHeadImmediately) {\n if (!sink.writeHead) {\n throw new TypeError(\"runFrameLoop: a status was requested but the sink has no writeHead\");\n }\n sink.writeHead(opts.status ?? 200, headers);\n } else if (sink.setHeader) {\n for (const [name, value] of Object.entries(headers)) sink.setHeader(name, value);\n }\n\n if (codec.prelude && !sink.writableEnded()) {\n const prelude = codec.prelude();\n if (prelude !== undefined) sink.write(prelude);\n }\n\n let chunkIndex = 0;\n for (const frame of frames) {\n if (codec.checkGoneBeforeDelay && sink.gone()) return false;\n const chunkDelay = calculateDelay(\n chunkIndex,\n timing.streamingProfile,\n timing.latency ?? 0,\n timing.recordedTimings,\n timing.replaySpeed,\n );\n if (chunkDelay > 0) await delay(chunkDelay, signal);\n if (stopped()) return false;\n if (codec.checkGoneBeforeDelay && sink.gone()) return false;\n if (sink.writableEnded()) return endedIsCompleted;\n sink.write(codec.encode(frame));\n if (codec.counts?.(frame) !== false) {\n chunkIndex++;\n opts.onChunkSent?.();\n }\n if (stopped()) return false;\n }\n\n // An abort the loop never observed (no frames) still stops the delivery.\n if (stopped()) return false;\n if (!sink.writableEnded()) {\n for (const part of codec.trailer?.(false) ?? []) sink.write(part);\n sink.end();\n }\n return true;\n}\n"],"mappings":";;;;AA+FA,SAAgB,SAAS,KAAqC;AAC5D,QAAO;EACL,YAAY,IAAI;EAChB,qBAAqB,IAAI;EACzB,mBAAmB,IAAI;EACvB,QAAQ,UAAU;AAChB,OAAI,MAAM,MAAM;;EAElB,WAAW;AACT,OAAI,KAAK;;EAEX,YAAY,MAAM,UAAU;AAC1B,OAAI,UAAU,MAAM,MAAM;;EAE5B,YAAY,QAAQ,YAAY;AAC9B,OAAI,UAAU,QAAQ,QAAQ;;EAEjC;;;AAIH,SAAgB,OAAO,IAAoC;AACzD,QAAO;EACL,YAAY,GAAG;EACf,qBAAqB;EACrB,QAAQ,UAAU;AAChB,MAAG,KAAK,OAAO,UAAU,WAAW,QAAQ,MAAM,SAAS,OAAO,CAAC;;EAErE,WAAW;EACZ;;;;;;;;;;;;;;;;;;;;;AAsBH,eAAsB,aACpB,MACA,QACA,OACA,QACA,OAAyB,EAAE,EACT;CAClB,MAAM,EAAE,cAAc;AACtB,KAAI,cAAc,UAAa,KAAK,WAAW,OAC7C,OAAM,IAAI,UAAU,uDAAuD;CAE7E,MAAM,SAAS,WAAW,UAAU,KAAK;CACzC,MAAM,gBACJ,QAAQ,YAAY,QAAS,cAAc,UAAa,KAAK,MAAM;CAGrE,MAAM,mBAAmB,cAAc;AACvC,KAAI,KAAK,eAAe,CAAE,QAAO;AACjC,KAAI,aAAa,KAAK,MAAM,CAAE,QAAO;AACrC,KAAI,aAAa,KAAK,eAAe,KAAK,KAAM,QAAO;CAEvD,MAAM,UACJ,KAAK,YAAY,SAAY,MAAM,UAAU;EAAE,GAAG,MAAM;EAAS,GAAG,KAAK;EAAS;AACpF,KAAI,KAAK,WAAW,UAAa,MAAM,sBAAsB;AAC3D,MAAI,CAAC,KAAK,UACR,OAAM,IAAI,UAAU,qEAAqE;AAE3F,OAAK,UAAU,KAAK,UAAU,KAAK,QAAQ;YAClC,KAAK,UACd,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,CAAE,MAAK,UAAU,MAAM,MAAM;AAGlF,KAAI,MAAM,WAAW,CAAC,KAAK,eAAe,EAAE;EAC1C,MAAM,UAAU,MAAM,SAAS;AAC/B,MAAI,YAAY,OAAW,MAAK,MAAM,QAAQ;;CAGhD,IAAI,aAAa;AACjB,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,MAAM,wBAAwB,KAAK,MAAM,CAAE,QAAO;EACtD,MAAM,aAAa,eACjB,YACA,OAAO,kBACP,OAAO,WAAW,GAClB,OAAO,iBACP,OAAO,YACR;AACD,MAAI,aAAa,EAAG,OAAM,MAAM,YAAY,OAAO;AACnD,MAAI,SAAS,CAAE,QAAO;AACtB,MAAI,MAAM,wBAAwB,KAAK,MAAM,CAAE,QAAO;AACtD,MAAI,KAAK,eAAe,CAAE,QAAO;AACjC,OAAK,MAAM,MAAM,OAAO,MAAM,CAAC;AAC/B,MAAI,MAAM,SAAS,MAAM,KAAK,OAAO;AACnC;AACA,QAAK,eAAe;;AAEtB,MAAI,SAAS,CAAE,QAAO;;AAIxB,KAAI,SAAS,CAAE,QAAO;AACtB,KAAI,CAAC,KAAK,eAAe,EAAE;AACzB,OAAK,MAAM,QAAQ,MAAM,UAAU,MAAM,IAAI,EAAE,CAAE,MAAK,MAAM,KAAK;AACjE,OAAK,KAAK;;AAEZ,QAAO"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const require_chaos = require('../chaos.cjs');
|
|
2
|
+
const require_interruption = require('../interruption.cjs');
|
|
3
|
+
|
|
4
|
+
//#region src/response/lifecycle.ts
|
|
5
|
+
/** The journal `interruptReason` for a client that left before the reply finished. */
|
|
6
|
+
const CLIENT_ABORTED = "client aborted";
|
|
7
|
+
/**
|
|
8
|
+
* The journal `interruptReason` for a response aimock tore down itself: the
|
|
9
|
+
* chaos `disconnect` action or a server-side `destroy`. It is the name the
|
|
10
|
+
* chaos journal entry records (`chaosAction: "disconnect"`), so a teardown
|
|
11
|
+
* aimock caused is never blamed on the client.
|
|
12
|
+
*/
|
|
13
|
+
const AIMOCK_DISCONNECTED = "disconnect";
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe to `close`, guarded the same way as `awaitChaosLatency`: a partial
|
|
16
|
+
* test double without `once` is not subscribed, and only a listener that was
|
|
17
|
+
* added is ever removed, through a remover that exists.
|
|
18
|
+
*/
|
|
19
|
+
function subscribeClose(source, onClose) {
|
|
20
|
+
if (typeof source.once !== "function") return void 0;
|
|
21
|
+
source.once("close", onClose);
|
|
22
|
+
if (typeof source.off === "function") return () => source.off?.("close", onClose);
|
|
23
|
+
if (typeof source.removeListener === "function") return () => source.removeListener?.("close", onClose);
|
|
24
|
+
}
|
|
25
|
+
function createLifecycle(interruption, goneAtStart, subscribe) {
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
let firstCause;
|
|
28
|
+
const abortWith = (cause) => {
|
|
29
|
+
if (controller.signal.aborted) return;
|
|
30
|
+
firstCause = cause;
|
|
31
|
+
controller.abort();
|
|
32
|
+
};
|
|
33
|
+
const onFixtureAbort = () => abortWith("fixture");
|
|
34
|
+
interruption?.signal.addEventListener("abort", onFixtureAbort, { once: true });
|
|
35
|
+
let unsubscribe;
|
|
36
|
+
if (goneAtStart !== void 0) abortWith(goneAtStart);
|
|
37
|
+
else unsubscribe = subscribe(abortWith);
|
|
38
|
+
return {
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
cause: () => firstCause,
|
|
41
|
+
reason: () => {
|
|
42
|
+
if (firstCause === "fixture") return interruption?.reason();
|
|
43
|
+
if (firstCause === "client") return CLIENT_ABORTED;
|
|
44
|
+
if (firstCause === "server") return AIMOCK_DISCONNECTED;
|
|
45
|
+
},
|
|
46
|
+
tick: () => {
|
|
47
|
+
interruption?.tick();
|
|
48
|
+
},
|
|
49
|
+
dispose: () => {
|
|
50
|
+
unsubscribe?.();
|
|
51
|
+
unsubscribe = void 0;
|
|
52
|
+
interruption?.signal.removeEventListener("abort", onFixtureAbort);
|
|
53
|
+
interruption?.cleanup();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The request socket of `res`, or `undefined` when it carries none. */
|
|
58
|
+
function requestSocket(res) {
|
|
59
|
+
const owner = res;
|
|
60
|
+
return owner.req?.socket ?? owner.socket ?? void 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* THE client hang-up rule, shared by the delivery lifecycle and the proxy
|
|
64
|
+
* relay: the client's socket closed or reset.
|
|
65
|
+
*
|
|
66
|
+
* - A reset, or a write into a socket the peer closed, leaves `errored` set
|
|
67
|
+
* (ECONNRESET / EPIPE).
|
|
68
|
+
* - aimock's server keeps Node's default `httpAllowHalfOpen: false`, so a
|
|
69
|
+
* client FIN makes Node end our write side: the socket is `writableEnded`
|
|
70
|
+
* while the response is not. That is the evidence the client closed.
|
|
71
|
+
*
|
|
72
|
+
* A FIN alone (`readableEnded` with neither of those) is not a hang-up: it is
|
|
73
|
+
* what the socket shows when aimock tore the response down before Node
|
|
74
|
+
* answered the FIN. A real client FIN that Node has not answered yet looks the
|
|
75
|
+
* same, so this rule also classifies it as not a hang-up. aimock's own
|
|
76
|
+
* `destroy()` leaves neither signal.
|
|
77
|
+
*/
|
|
78
|
+
function closedByClient(socket) {
|
|
79
|
+
return (socket.errored ?? null) !== null || socket.writableEnded === true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Who tore down a response that is gone without having ended. After the fact
|
|
83
|
+
* `res.destroyed` reads the same either way, so this reads the evidence:
|
|
84
|
+
*
|
|
85
|
+
* - a chaos `disconnect` marked the response before destroying it: aimock;
|
|
86
|
+
* - the request socket meets {@link closedByClient}: the client;
|
|
87
|
+
* - neither: aimock destroyed it server-side.
|
|
88
|
+
*
|
|
89
|
+
* `socket` defaults to the response's request socket. With no socket to read
|
|
90
|
+
* (a partial test double) the client attribution is kept.
|
|
91
|
+
*/
|
|
92
|
+
function teardownCause(res, socket = requestSocket(res)) {
|
|
93
|
+
if (require_chaos.responseGoneReason(res) === "chaos-disconnected") return "server";
|
|
94
|
+
if (!socket) return "client";
|
|
95
|
+
return closedByClient(socket) ? "client" : "server";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The request socket's `bytesWritten` when each delivery started, so the
|
|
99
|
+
* status-line rule counts only this response's bytes on a keep-alive socket.
|
|
100
|
+
*/
|
|
101
|
+
const deliveryStartBytes = /* @__PURE__ */ new WeakMap();
|
|
102
|
+
/**
|
|
103
|
+
* Record where this response's bytes start on its socket. A response whose
|
|
104
|
+
* head was already committed when the delivery started is not recorded:
|
|
105
|
+
* {@link statusLineSent} keeps the `headersSent` rule for it.
|
|
106
|
+
*/
|
|
107
|
+
function noteDeliveryStart(res) {
|
|
108
|
+
if (deliveryStartBytes.has(res) || res.headersSent) return;
|
|
109
|
+
const bytes = requestSocket(res)?.bytesWritten;
|
|
110
|
+
if (typeof bytes === "number") deliveryStartBytes.set(res, bytes);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether this response's status line reached the client's socket: a byte of
|
|
114
|
+
* it was handed to the socket after {@link noteDeliveryStart}. `headersSent`
|
|
115
|
+
* is not that test: `writeHead` sets it before any byte is written. With no
|
|
116
|
+
* recorded start, or no socket to read, `headersSent` is the fallback.
|
|
117
|
+
*/
|
|
118
|
+
function statusLineSent(res) {
|
|
119
|
+
const start = deliveryStartBytes.get(res);
|
|
120
|
+
const bytes = requestSocket(res)?.bytesWritten;
|
|
121
|
+
if (start === void 0 || typeof bytes !== "number") return res.headersSent;
|
|
122
|
+
return bytes > start;
|
|
123
|
+
}
|
|
124
|
+
/** The journal `interruptReason` for {@link teardownCause}. */
|
|
125
|
+
function teardownReason(res) {
|
|
126
|
+
return teardownCause(res) === "client" ? CLIENT_ABORTED : AIMOCK_DISCONNECTED;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* HTTP delivery lifecycle. A `close` before `res.writableFinished` aborts it,
|
|
130
|
+
* and {@link teardownCause} decides whose teardown it was: the client
|
|
131
|
+
* (`"client"`) or aimock itself (`"server"`). A response that is already gone
|
|
132
|
+
* at construction aborts at once with the same attribution. A chaos
|
|
133
|
+
* `disconnect` destroys without ending, so no `writableEnded` check stops a
|
|
134
|
+
* writer for it. Only an already-ended response does not abort: the frame
|
|
135
|
+
* loop exits on its `writableEnded` check. The fixture interruption is
|
|
136
|
+
* created only when `streaming` is true (it is streaming-only).
|
|
137
|
+
*/
|
|
138
|
+
function httpLifecycle(res, fixture, opts) {
|
|
139
|
+
const interruption = opts.streaming && fixture ? require_interruption.createInterruptionSignal(fixture) : null;
|
|
140
|
+
const gone = require_chaos.responseGoneReason(res);
|
|
141
|
+
return createLifecycle(interruption, gone === null || gone === "already-ended" ? void 0 : teardownCause(res), (onGone) => subscribeClose(res, () => {
|
|
142
|
+
if (!res.writableFinished) onGone(teardownCause(res));
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Per watched connection: whether its `close` event has fired. Only sockets
|
|
147
|
+
* watched while open are recorded, so an entry is evidence, not a guess.
|
|
148
|
+
*/
|
|
149
|
+
const wsCloseEmitted = /* @__PURE__ */ new WeakMap();
|
|
150
|
+
/**
|
|
151
|
+
* Start recording when `ws` emits `close`, so {@link wsClosedCause} can tell
|
|
152
|
+
* aimock's own close from the client's. Each WS transport calls it when the
|
|
153
|
+
* connection opens. Idempotent, and a no-op once closed.
|
|
154
|
+
*/
|
|
155
|
+
function watchWsClose(ws) {
|
|
156
|
+
if (ws.isClosed || wsCloseEmitted.has(ws)) return;
|
|
157
|
+
const source = ws;
|
|
158
|
+
if (typeof source.once !== "function") return;
|
|
159
|
+
wsCloseEmitted.set(ws, false);
|
|
160
|
+
source.once("close", () => wsCloseEmitted.set(ws, true));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Who closed a socket that reads `isClosed`, or `undefined` while it is open.
|
|
164
|
+
*
|
|
165
|
+
* `WebSocketConnection` marks itself closed and emits `close` in the same step
|
|
166
|
+
* for a client close frame, a lost connection, and `destroy()`. Only aimock's
|
|
167
|
+
* own `close()` (a write or buffer limit, a bad frame, a strict miss, shutdown)
|
|
168
|
+
* marks it closed at once and emits `close` about 100ms later. So a socket
|
|
169
|
+
* watched while open ({@link watchWsClose}) that is closed with no `close` yet
|
|
170
|
+
* was closed by aimock (`"server"`). Any other closed socket reads as the
|
|
171
|
+
* client leaving, as before.
|
|
172
|
+
*/
|
|
173
|
+
function wsClosedCause(ws) {
|
|
174
|
+
if (!ws.isClosed) return void 0;
|
|
175
|
+
return wsCloseEmitted.get(ws) === false ? "server" : "client";
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* WebSocket turn lifecycle. A WS turn is always a stream, so it always owns
|
|
179
|
+
* the fixture interruption. A `close` means the client left. A socket already
|
|
180
|
+
* closed at construction aborts at once with {@link wsClosedCause}. Callers
|
|
181
|
+
* never create their own interruption object.
|
|
182
|
+
*/
|
|
183
|
+
function wsLifecycle(ws, fixture) {
|
|
184
|
+
return createLifecycle(fixture ? require_interruption.createInterruptionSignal(fixture) : null, wsClosedCause(ws), (onGone) => subscribeClose(ws, () => onGone("client")));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Journal a WS turn that stopped because its lifecycle aborted or its socket
|
|
188
|
+
* closed. This is the closed-socket rule every WS transport uses: the
|
|
189
|
+
* lifecycle's reason when it has one, else the closed socket's cause
|
|
190
|
+
* ({@link wsClosedCause}): {@link AIMOCK_DISCONNECTED} for aimock's own close,
|
|
191
|
+
* {@link CLIENT_ABORTED} for the client. A turn with no lifecycle (it never
|
|
192
|
+
* waits) passes none.
|
|
193
|
+
*/
|
|
194
|
+
function markWsTurnStopped(entry, ws, lifecycle) {
|
|
195
|
+
const cause = wsClosedCause(ws);
|
|
196
|
+
const socketReason = cause === "server" ? AIMOCK_DISCONNECTED : cause === "client" ? CLIENT_ABORTED : void 0;
|
|
197
|
+
markInterrupted(entry, lifecycle?.reason() ?? socketReason);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Mark a journal entry interrupted. The reason is set only when the entry has
|
|
201
|
+
* none, so a later `undefined` never erases an earlier reason. The status is
|
|
202
|
+
* never changed: the entry keeps the status it was journaled with.
|
|
203
|
+
*/
|
|
204
|
+
function markInterrupted(entry, reason) {
|
|
205
|
+
entry.response.interrupted = true;
|
|
206
|
+
if (entry.response.interruptReason === void 0 && reason !== void 0) entry.response.interruptReason = reason;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//#endregion
|
|
210
|
+
exports.AIMOCK_DISCONNECTED = AIMOCK_DISCONNECTED;
|
|
211
|
+
exports.CLIENT_ABORTED = CLIENT_ABORTED;
|
|
212
|
+
exports.closedByClient = closedByClient;
|
|
213
|
+
exports.httpLifecycle = httpLifecycle;
|
|
214
|
+
exports.markInterrupted = markInterrupted;
|
|
215
|
+
exports.markWsTurnStopped = markWsTurnStopped;
|
|
216
|
+
exports.noteDeliveryStart = noteDeliveryStart;
|
|
217
|
+
exports.requestSocket = requestSocket;
|
|
218
|
+
exports.statusLineSent = statusLineSent;
|
|
219
|
+
exports.teardownCause = teardownCause;
|
|
220
|
+
exports.teardownReason = teardownReason;
|
|
221
|
+
exports.watchWsClose = watchWsClose;
|
|
222
|
+
exports.wsLifecycle = wsLifecycle;
|
|
223
|
+
//# sourceMappingURL=lifecycle.cjs.map
|