@telorun/http-server 0.3.1 → 0.3.3

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.
@@ -0,0 +1,179 @@
1
+ import { runSinkContract, type SinkHandle } from "@telorun/http-dispatch/test-utils";
2
+ import type { CapturedResponse } from "@telorun/http-dispatch/test-utils";
3
+ import type { ResponseSink } from "@telorun/http-dispatch";
4
+ import Fastify from "fastify";
5
+ import http from "node:http";
6
+ import type { AddressInfo } from "node:net";
7
+ import { fastifyReplySink } from "../src/fastify-reply-sink.js";
8
+
9
+ /** Drives `fastifyReplySink` through the shared `runSinkContract` harness so
10
+ * the production Fastify adapter is held to the same status / header / send /
11
+ * stream contract as every other transport adapter.
12
+ *
13
+ * The harness expects `setStatus`/`setHeader` to be callable synchronously the
14
+ * moment `makeSink()` returns, but Fastify only hands a real `FastifyReply`
15
+ * to a handler after a request lands. The wrapper queues sync calls until
16
+ * the route handler runs, then replays them against the real sink — async
17
+ * calls (`send` / `stream`) wire their returned promise to the real
18
+ * operation's outcome so awaiting code sees the correct success/failure.
19
+ *
20
+ * Uses a real listening server (not `app.inject`): light-my-request rejects
21
+ * on the destroyed-stream path that mid-flight errors take, which would
22
+ * hide whether the partial body actually made it to the wire — exactly the
23
+ * thing the contract's stream-failure case asserts. */
24
+ function makeFastifySink(): SinkHandle {
25
+ type Op = (real: ResponseSink) => void | Promise<void>;
26
+ const queue: Op[] = [];
27
+ let real: ResponseSink | undefined;
28
+ let usedStream = false;
29
+
30
+ let resolveResult!: (r: CapturedResponse) => void;
31
+ let rejectResult!: (e: unknown) => void;
32
+ const result = new Promise<CapturedResponse>((res, rej) => {
33
+ resolveResult = res;
34
+ rejectResult = rej;
35
+ });
36
+
37
+ async function flush(target: ResponseSink): Promise<void> {
38
+ while (queue.length > 0) {
39
+ const op = queue.shift()!;
40
+ await op(target);
41
+ }
42
+ }
43
+
44
+ const sink: ResponseSink = {
45
+ setStatus(code) {
46
+ if (real) {
47
+ real.setStatus(code);
48
+ return;
49
+ }
50
+ queue.push((s) => s.setStatus(code));
51
+ },
52
+ setHeader(name, value) {
53
+ if (real) {
54
+ real.setHeader(name, value);
55
+ return;
56
+ }
57
+ queue.push((s) => s.setHeader(name, value));
58
+ },
59
+ async send(body) {
60
+ if (real) {
61
+ await real.send(body);
62
+ return;
63
+ }
64
+ await new Promise<void>((res, rej) => {
65
+ queue.push(async (s) => {
66
+ try {
67
+ await s.send(body);
68
+ res();
69
+ } catch (e) {
70
+ rej(e);
71
+ }
72
+ });
73
+ });
74
+ },
75
+ async stream(iter, onError) {
76
+ usedStream = true;
77
+ if (real) {
78
+ await real.stream(iter, onError);
79
+ return;
80
+ }
81
+ await new Promise<void>((res, rej) => {
82
+ queue.push(async (s) => {
83
+ try {
84
+ await s.stream(iter, onError);
85
+ res();
86
+ } catch (e) {
87
+ rej(e);
88
+ }
89
+ });
90
+ });
91
+ },
92
+ };
93
+
94
+ const app = Fastify();
95
+ app.route({
96
+ method: "POST",
97
+ url: "/",
98
+ handler: async (_req, reply) => {
99
+ real = fastifyReplySink(reply);
100
+ await flush(real);
101
+ },
102
+ });
103
+
104
+ void (async () => {
105
+ try {
106
+ await app.listen({ host: "127.0.0.1", port: 0 });
107
+ const addr = app.server.address() as AddressInfo;
108
+ await new Promise<void>((settle) => {
109
+ let settled = false;
110
+ const finish = (build: () => CapturedResponse) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ try {
114
+ resolveResult(build());
115
+ } catch (e) {
116
+ rejectResult(e);
117
+ }
118
+ settle();
119
+ };
120
+
121
+ const req = http.request({
122
+ host: addr.address,
123
+ port: addr.port,
124
+ path: "/",
125
+ method: "POST",
126
+ });
127
+ req.on("error", (e) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ rejectResult(e);
131
+ settle();
132
+ });
133
+ req.on("response", (res) => {
134
+ const chunks: Buffer[] = [];
135
+ res.on("data", (c) => chunks.push(c as Buffer));
136
+ // Both `end` (clean) and `close`/`aborted` (mid-stream destroy) end
137
+ // the response from the client's perspective. The contract treats
138
+ // both as terminal: whatever bytes arrived are the captured body,
139
+ // and `onError` (if provided) was already invoked on the server.
140
+ const buildCaptured = (): CapturedResponse => {
141
+ let total = 0;
142
+ for (const c of chunks) total += c.byteLength;
143
+ const body = new Uint8Array(total);
144
+ let off = 0;
145
+ for (const c of chunks) {
146
+ body.set(c, off);
147
+ off += c.byteLength;
148
+ }
149
+ const headers: Record<string, string> = {};
150
+ for (const [k, v] of Object.entries(res.headers)) {
151
+ if (v == null) continue;
152
+ headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v);
153
+ }
154
+ return {
155
+ status: res.statusCode ?? 0,
156
+ headers,
157
+ body,
158
+ isStream: usedStream,
159
+ };
160
+ };
161
+ res.on("end", () => finish(buildCaptured));
162
+ res.on("close", () => finish(buildCaptured));
163
+ res.on("aborted", () => finish(buildCaptured));
164
+ });
165
+ req.end();
166
+ });
167
+ } catch (e) {
168
+ rejectResult(e);
169
+ } finally {
170
+ await app.close().catch(() => {
171
+ /* server close after partial-response abort can race; harmless */
172
+ });
173
+ }
174
+ })();
175
+
176
+ return { sink, result };
177
+ }
178
+
179
+ runSinkContract("fastifyReplySink", makeFastifySink);
package/tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.base.json",
3
3
  "files": [],
4
- "include": ["src/**/*.ts", "test/**/*.ts"],
4
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
5
5
  "compilerOptions": {
6
6
  "strict": true,
7
7
  "esModuleInterop": true
@@ -1,10 +1,10 @@
1
1
  {
2
- "extends": "../../tsconfig.base.json",
2
+ "extends": "../../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "strict": true,
5
5
  "esModuleInterop": true,
6
- "types": ["node", "jest"]
6
+ "types": ["node"]
7
7
  },
8
8
  "files": [],
9
- "include": ["test/**/*.ts"]
9
+ "include": ["tests/**/*.ts"]
10
10
  }