@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.
- package/CHANGELOG.md +52 -0
- package/README.md +0 -4
- package/dist/fastify-reply-sink.d.ts +12 -0
- package/dist/fastify-reply-sink.js +64 -0
- package/dist/http-api-controller.d.ts +5 -117
- package/dist/http-api-controller.js +5 -430
- package/dist/http-server-controller.d.ts +1 -1
- package/dist/http-server-controller.js +5 -3
- package/package.json +8 -4
- package/src/fastify-reply-sink.ts +67 -0
- package/src/http-api-controller.ts +15 -540
- package/src/http-server-controller.ts +12 -9
- package/tests/fastify-reply-sink-contract.test.ts +179 -0
- package/tsconfig.json +1 -1
- package/tsconfig.spec.json +3 -3
|
@@ -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
package/tsconfig.spec.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"strict": true,
|
|
5
5
|
"esModuleInterop": true,
|
|
6
|
-
"types": ["node"
|
|
6
|
+
"types": ["node"]
|
|
7
7
|
},
|
|
8
8
|
"files": [],
|
|
9
|
-
"include": ["
|
|
9
|
+
"include": ["tests/**/*.ts"]
|
|
10
10
|
}
|