@webda/grpc 4.0.0-beta.5 → 4.0.0-beta.7

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,114 @@
1
+ import { suite, test } from "@webda/test";
2
+ import * as assert from "assert";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import * as grpc from "@grpc/grpc-js";
7
+ import * as protoLoader from "@grpc/proto-loader";
8
+ import WebSocket from "ws";
9
+ import { useInstanceStorage, useService } from "@webda/core";
10
+ import { HttpServer } from "@webda/core/lib/services/httpserver.service.js";
11
+ import { WebdaApplicationTest } from "@webda/core/lib/test/application.js";
12
+ import { TestApplication } from "@webda/core/lib/test/objects.js";
13
+ import { GRPC_FIXTURE_SERVICES, GrpcFixtureService, fixtureState, registerGrpcFixture } from "../test/fixture.js";
14
+ import { GrpcService } from "./grpcservice.service.js";
15
+
16
+ const protoFile = join(mkdtempSync(join(tmpdir(), "connect-e2e-")), "app.proto");
17
+
18
+ /**
19
+ * @param check - condition
20
+ */
21
+ async function until(check: () => boolean): Promise<void> {
22
+ for (let i = 0; i < 500 && !check(); i++) await new Promise(r => setTimeout(r, 10));
23
+ if (!check()) throw new Error("timed out");
24
+ }
25
+
26
+ /** The same `async *connect(frames)` served as a gRPC bidi stream (h2c) and as a WebSocket (HTTP/1.1). */
27
+ @suite
28
+ class ConnectEndToEndTest extends WebdaApplicationTest {
29
+ getTestConfiguration(): any {
30
+ return {
31
+ services: {
32
+ ...GRPC_FIXTURE_SERVICES,
33
+ HttpServer: { type: "Webda/HttpServer", port: 0 },
34
+ GrpcServer: { type: "Webda/HttpServer", port: 0, h2c: true },
35
+ RESTService: { type: "Webda/RESTOperationsTransport" },
36
+ Grpc: { type: "Webda/GrpcService", protoFile, operations: ["Fixture.*"] }
37
+ }
38
+ };
39
+ }
40
+
41
+ async tweakApp(app: TestApplication): Promise<void> {
42
+ await super.tweakApp(app);
43
+ app.addModda("Webda/GrpcFixtureService", GrpcFixtureService);
44
+ app.addModda("Webda/HttpServer", HttpServer);
45
+ app.addModda("Webda/GrpcService", GrpcService);
46
+ }
47
+
48
+ @test
49
+ async sameOperationOnBothTransports() {
50
+ registerGrpcFixture();
51
+ const grpcService = useService("Grpc" as any) as unknown as GrpcService;
52
+ await grpcService.build();
53
+ grpcService.loadDefinitions();
54
+ (useService("RESTService" as any) as any).exposeServiceOperations({
55
+ "Fixture.Connect": useInstanceStorage().operations["Fixture.Connect"]
56
+ });
57
+ const http = useService("HttpServer" as any) as any;
58
+ const h2c = useService("GrpcServer" as any) as any;
59
+ await http.start("127.0.0.1", 0);
60
+ await h2c.start("127.0.0.1", 0);
61
+ let client: any;
62
+ let ws: WebSocket | undefined;
63
+ try {
64
+ await until(() => http.server?.listening && h2c.server?.listening);
65
+
66
+ // gRPC
67
+ fixtureState.connectClosed = false;
68
+ const definition = protoLoader.loadSync(protoFile, { keepCase: true, defaults: true, oneofs: true });
69
+ const pkg: any = grpc.loadPackageDefinition(definition).webda;
70
+ client = new pkg.FixtureService(`127.0.0.1:${h2c.server.address().port}`, grpc.credentials.createInsecure());
71
+ const metadata = new grpc.Metadata();
72
+ metadata.set("authorization", "Bearer e2e");
73
+ const call = client.Connect(metadata);
74
+ const overGrpc: string[] = [];
75
+ let grpcStatus: any;
76
+ call.on("data", (m: any) => overGrpc.push(m.frame));
77
+ call.on("status", (s: any) => (grpcStatus = s));
78
+ call.on("error", () => {});
79
+ await until(() => overGrpc.length === 1);
80
+ call.write({ frame: "x" });
81
+ await until(() => overGrpc.length === 2);
82
+ call.end();
83
+ await until(() => grpcStatus !== undefined);
84
+ assert.strictEqual(grpcStatus.code, grpc.status.OK);
85
+ await until(() => fixtureState.connectClosed);
86
+
87
+ // WebSocket
88
+ fixtureState.connectClosed = false;
89
+ ws = new WebSocket(`ws://127.0.0.1:${http.server.address().port}/fixture/connect`, {
90
+ headers: { authorization: "Bearer e2e" }
91
+ });
92
+ const overWs: string[] = [];
93
+ ws.on("message", data => overWs.push(JSON.parse(String(data)).frame));
94
+ let wsClose: number | undefined;
95
+ ws.on("close", code => (wsClose = code));
96
+ await new Promise(r => ws!.on("open", r));
97
+ await until(() => overWs.length === 1);
98
+ ws.send(JSON.stringify({ frame: "x" }));
99
+ await until(() => overWs.length === 2);
100
+ ws.close(1000);
101
+ await until(() => wsClose !== undefined);
102
+ assert.strictEqual(wsClose, 1000);
103
+ await until(() => fixtureState.connectClosed);
104
+
105
+ assert.deepStrictEqual(overGrpc, ["hello Bearer e2e", "echo x Bearer e2e"]);
106
+ assert.deepStrictEqual(overWs, overGrpc);
107
+ } finally {
108
+ client?.close();
109
+ ws?.terminate();
110
+ await http.stop();
111
+ await h2c.stop();
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,93 @@
1
+ import { once } from "node:events";
2
+ import type { ServerResponse } from "node:http";
3
+ import { HttpContext, StreamingOperationContext, toPublicChunk } from "@webda/core";
4
+ import type { GrpcStream } from "./grpc-stream.js";
5
+
6
+ /**
7
+ * A gRPC message for a value: objects as they are, anything else as `{ value }`, `__` keys removed
8
+ * @param value - an operation result or streamed chunk
9
+ * @returns the message
10
+ */
11
+ export function toGrpcMessage(value: unknown): Record<string, unknown> {
12
+ const plain = toPublicChunk(value);
13
+ return typeof plain === "object" && plain !== null && !Array.isArray(plain)
14
+ ? (plain as Record<string, unknown>)
15
+ : { value: plain };
16
+ }
17
+
18
+ /**
19
+ * The context of one gRPC call: request metadata as HTTP headers (so useContext().getHttpContext() works and the
20
+ * session loads), the unary request message as input, and streamed output sent message by message.
21
+ */
22
+ export class GrpcOperationContext extends StreamingOperationContext {
23
+ private message?: Buffer;
24
+
25
+ /**
26
+ * @param httpContext - request line and metadata
27
+ * @param stream - the gRPC stream of the call
28
+ * @param response - the HTTP/2 response (drain/close events)
29
+ */
30
+ constructor(
31
+ httpContext: HttpContext,
32
+ private readonly stream: GrpcStream,
33
+ private readonly response: ServerResponse
34
+ ) {
35
+ super(httpContext);
36
+ }
37
+
38
+ /**
39
+ * The single request message of a unary or server-streaming call
40
+ * @param message - decoded message
41
+ * @returns this
42
+ */
43
+ setMessage(message: unknown): this {
44
+ this.message = Buffer.from(JSON.stringify(message ?? {}));
45
+ return this;
46
+ }
47
+
48
+ /**
49
+ * @override
50
+ */
51
+ async getRawInput(limit: number = 10 * 1024 * 1024): Promise<Buffer> {
52
+ return (this.message ?? Buffer.from("{}")).subarray(0, limit);
53
+ }
54
+
55
+ /**
56
+ * `getInput()` reads the body through this method, not through `getRawInput()`
57
+ * @override
58
+ */
59
+ async getRawInputAsString(limit?: number): Promise<string> {
60
+ return (await this.getRawInput(limit)).toString();
61
+ }
62
+
63
+ /**
64
+ * The response is over
65
+ * @override
66
+ */
67
+ protected get connectionEnded(): boolean {
68
+ const response = this.response as { writableEnded?: boolean; destroyed?: boolean };
69
+ return Boolean(response.writableEnded || response.destroyed);
70
+ }
71
+
72
+ /**
73
+ * One gRPC message per chunk
74
+ * @override
75
+ */
76
+ protected sendChunk(chunk: any): boolean {
77
+ return this.stream.send(toGrpcMessage(chunk));
78
+ }
79
+
80
+ /**
81
+ * Wait for the response buffer to empty, or the response to close or fail
82
+ * @override
83
+ */
84
+ protected async waitForDrain(signal: AbortSignal): Promise<void> {
85
+ await Promise.race([
86
+ once(this.response, "drain", { signal }),
87
+ once(this.response, "close", { signal }),
88
+ once(this.response, "error", { signal }).then(([error]) => {
89
+ throw error;
90
+ })
91
+ ]);
92
+ }
93
+ }
@@ -0,0 +1,377 @@
1
+ import { suite, test } from "@webda/test";
2
+ import * as assert from "assert";
3
+ import { EventEmitter } from "node:events";
4
+ import { connect } from "node:http2";
5
+ import { mkdtempSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import * as grpc from "@grpc/grpc-js";
9
+ import * as protoLoader from "@grpc/proto-loader";
10
+ import { HttpContext, WebdaError, useRouter, useService } from "@webda/core";
11
+ import { HttpServer } from "@webda/core/lib/services/httpserver.service.js";
12
+ import { WebdaApplicationTest } from "@webda/core/lib/test/application.js";
13
+ import { TestApplication } from "@webda/core/lib/test/objects.js";
14
+ import { GRPC_FIXTURE_SERVICES, GrpcFixtureService, fixtureState, registerGrpcFixture } from "../test/fixture.js";
15
+ import { GrpcOperationContext, toGrpcMessage } from "./grpc-context.js";
16
+ import { GrpcService } from "./grpcservice.service.js";
17
+
18
+ const protoFile = join(mkdtempSync(join(tmpdir(), "grpc-live-")), "app.proto");
19
+
20
+ /**
21
+ * Polls until a condition holds
22
+ * @param check - the condition
23
+ * @param ms - timeout
24
+ */
25
+ async function until(check: () => boolean, ms = 5000): Promise<void> {
26
+ const end = Date.now() + ms;
27
+ while (!check()) {
28
+ if (Date.now() > end) throw new Error("timed out");
29
+ await new Promise(r => setTimeout(r, 10));
30
+ }
31
+ }
32
+
33
+ @suite
34
+ class GrpcLiveTest extends WebdaApplicationTest {
35
+ client: any;
36
+
37
+ getTestConfiguration(): any {
38
+ return {
39
+ services: {
40
+ ...GRPC_FIXTURE_SERVICES,
41
+ HttpServer: { type: "Webda/HttpServer", port: 0, h2c: true },
42
+ Grpc: {
43
+ type: "Webda/GrpcService",
44
+ protoFile,
45
+ operations: ["Fixture.*"],
46
+ maxQueuedMessages: 5,
47
+ maxQueuedBytes: 1000
48
+ }
49
+ }
50
+ };
51
+ }
52
+
53
+ async tweakApp(app: TestApplication): Promise<void> {
54
+ await super.tweakApp(app);
55
+ app.addModda("Webda/GrpcFixtureService", GrpcFixtureService);
56
+ app.addModda("Webda/HttpServer", HttpServer);
57
+ app.addModda("Webda/GrpcService", GrpcService);
58
+ }
59
+
60
+ /** Registers the fixture, writes and loads its proto, starts the h2c server once, and connects a client. */
61
+ async connect(): Promise<any> {
62
+ if (this.client) return this.client;
63
+ registerGrpcFixture();
64
+ const service = useService("Grpc" as any) as unknown as GrpcService;
65
+ await service.build();
66
+ service.loadDefinitions();
67
+ const http = useService("HttpServer" as any) as any;
68
+ await http.start("127.0.0.1", 0);
69
+ await until(() => http.server?.listening);
70
+ const definition = protoLoader.loadSync(protoFile, {
71
+ keepCase: true,
72
+ longs: String,
73
+ enums: String,
74
+ defaults: true,
75
+ oneofs: false // the client would add `_field` markers for proto3 optional fields
76
+ });
77
+ const pkg: any = grpc.loadPackageDefinition(definition).webda;
78
+ this.client = new pkg.FixtureService(`127.0.0.1:${http.server.address().port}`, grpc.credentials.createInsecure());
79
+ return this.client;
80
+ }
81
+
82
+ async afterAll() {
83
+ this.client?.close();
84
+ await super.afterAll?.();
85
+ }
86
+
87
+ @test
88
+ async unaryCallsSeeTheMetadata() {
89
+ const client = await this.connect();
90
+ const metadata = new grpc.Metadata();
91
+ metadata.set("authorization", "Bearer u1");
92
+ const out = await new Promise<any>((resolve, reject) =>
93
+ client.Echo({ text: "hi" }, metadata, (err: Error, res: any) => (err ? reject(err) : resolve(res)))
94
+ );
95
+ assert.deepStrictEqual(out, { text: "hi", authorization: "Bearer u1" });
96
+ }
97
+
98
+ @test
99
+ async unaryErrorsBeforeAnyMessageAreStatuses() {
100
+ const client = await this.connect();
101
+ const err: any = await new Promise(resolve => client.Echo({ text: "boom" }, (e: Error) => resolve(e)));
102
+ assert.strictEqual(err.code, grpc.status.NOT_FOUND);
103
+ assert.match(err.details, /Nothing to echo/);
104
+ }
105
+
106
+ @test
107
+ async aFailedStreamIsNotCalledOrWrittenTo() {
108
+ await this.connect();
109
+ const http = useService("HttpServer" as any) as any;
110
+ const session = connect(`http://127.0.0.1:${http.server.address().port}`);
111
+ const request = session.request({
112
+ ":method": "POST",
113
+ ":path": "/webda.FixtureService/Echo",
114
+ "content-type": "application/grpc"
115
+ });
116
+ const trailers = new Promise<any>(resolve => request.on("trailers", resolve));
117
+ const headers = new Promise<any>(resolve => request.on("response", resolve));
118
+ request.on("data", () => {});
119
+ // A compressed frame: the stream fails with UNIMPLEMENTED and then sees the end of the request
120
+ request.end(Buffer.from([1, 0, 0, 0, 2, 123, 125]));
121
+ const received = { ...(await headers), ...(await Promise.race([trailers, new Promise(r => setTimeout(r, 200))])) };
122
+ session.close();
123
+ assert.strictEqual(received["grpc-status"], String(grpc.status.UNIMPLEMENTED));
124
+ }
125
+
126
+ @test
127
+ async serverStreamingIsLive() {
128
+ const client = await this.connect();
129
+ fixtureState.ticksClosed = false;
130
+ const got: number[] = [];
131
+ const call = client.Ticks({ n: 3 });
132
+ call.on("data", (m: any) => got.push(m.index));
133
+ const ended = new Promise(resolve => call.on("status", resolve));
134
+ await until(() => got.length === 1);
135
+ await new Promise(r => setTimeout(r, 50));
136
+ assert.deepStrictEqual(got, [1]); // the generator is still waiting at its gate
137
+ fixtureState.open();
138
+ const status: any = await ended;
139
+ assert.strictEqual(status.code, grpc.status.OK);
140
+ assert.deepStrictEqual(got, [1, 2, 3]);
141
+ assert.strictEqual(fixtureState.ticksClosed, true);
142
+ }
143
+
144
+ @test
145
+ async clientStreamingSums() {
146
+ const client = await this.connect();
147
+ const total = await new Promise<number>((resolve, reject) => {
148
+ const call = client.Sum((err: Error, res: any) => (err ? reject(err) : resolve(res.total)));
149
+ call.write({ value: 2 });
150
+ call.write({ value: 3 });
151
+ call.end();
152
+ });
153
+ assert.strictEqual(total, 5);
154
+ }
155
+
156
+ @test
157
+ async bidiIsLongLivedAndSeesTheMetadata() {
158
+ const client = await this.connect();
159
+ fixtureState.connectClosed = false;
160
+ const metadata = new grpc.Metadata();
161
+ metadata.set("authorization", "Bearer t1");
162
+ const call = client.Connect(metadata);
163
+ const got: string[] = [];
164
+ call.on("data", (m: any) => got.push(m.frame));
165
+ const status = new Promise<any>(resolve => call.on("status", resolve));
166
+ await until(() => got.length === 1);
167
+ call.write({ frame: "a" });
168
+ await until(() => got.length === 2);
169
+ call.write({ frame: "b" });
170
+ await until(() => got.length === 3);
171
+ assert.deepStrictEqual(got, ["hello Bearer t1", "echo a Bearer t1", "echo b Bearer t1"]);
172
+ call.end(); // half-close: the input stream ends, the generator finishes
173
+ assert.strictEqual((await status).code, grpc.status.OK);
174
+ assert.strictEqual(fixtureState.connectClosed, true);
175
+ }
176
+
177
+ @test
178
+ async cancelClosesTheGenerator() {
179
+ const client = await this.connect();
180
+ fixtureState.connectClosed = false;
181
+ const call = client.Connect(new grpc.Metadata());
182
+ const got: string[] = [];
183
+ call.on("data", (m: any) => got.push(m.frame));
184
+ call.on("error", () => {}); // CANCELLED, expected
185
+ await until(() => got.length === 1);
186
+ call.cancel();
187
+ await until(() => fixtureState.connectClosed);
188
+ }
189
+
190
+ @test
191
+ async errorsBecomeStatuses() {
192
+ const client = await this.connect();
193
+ const call = client.Connect(new grpc.Metadata());
194
+ const error = new Promise<any>(resolve => call.on("error", resolve));
195
+ call.on("data", () => {});
196
+ call.write({ frame: "fail" });
197
+ const err = await error;
198
+ assert.strictEqual(err.code, grpc.status.NOT_FOUND);
199
+ assert.match(err.details, /No such frame/);
200
+ }
201
+
202
+ @test
203
+ async internalErrorMessagesAreHidden() {
204
+ const client = await this.connect();
205
+ const err: any = await new Promise(resolve => client.Leak({ text: "x" }, (e: Error) => resolve(e)));
206
+ assert.strictEqual(err.code, grpc.status.INTERNAL);
207
+ assert.ok(!/secret/.test(err.details), err.details);
208
+ assert.match(err.details, /Internal server error/);
209
+ // A client error keeps its message
210
+ const notFound: any = await new Promise(resolve => client.Echo({ text: "boom" }, (e: Error) => resolve(e)));
211
+ assert.strictEqual(notFound.code, grpc.status.NOT_FOUND);
212
+ assert.match(notFound.details, /Nothing to echo/);
213
+ }
214
+
215
+ @test
216
+ async requestFiltersApplyToCalls() {
217
+ const client = await this.connect();
218
+ useRouter().registerRequestFilter({
219
+ checkRequest: async ctx => ctx.getHttpContext().getUniqueHeader("x-block") !== "yes"
220
+ });
221
+ fixtureState.echoCalls = 0;
222
+ const blocked = new grpc.Metadata();
223
+ blocked.set("x-block", "yes");
224
+ const err: any = await new Promise(resolve => client.Echo({ text: "hi" }, blocked, (e: Error) => resolve(e)));
225
+ assert.strictEqual(err.code, grpc.status.PERMISSION_DENIED);
226
+ assert.strictEqual(fixtureState.echoCalls, 0, "the operation must not run");
227
+ const out = await new Promise<any>((resolve, reject) =>
228
+ client.Echo({ text: "hi" }, (e: Error, res: any) => (e ? reject(e) : resolve(res)))
229
+ );
230
+ assert.strictEqual(out.text, "hi");
231
+ assert.strictEqual(fixtureState.echoCalls, 1);
232
+ }
233
+
234
+ @test
235
+ async floodingAnOperationThatDoesNotReadFailsWithResourceExhausted() {
236
+ const client = await this.connect();
237
+ fixtureState.floodClosed = false;
238
+ const call = client.Flood(new grpc.Metadata());
239
+ call.on("data", () => {});
240
+ const error = new Promise<any>(resolve => call.on("error", resolve));
241
+ for (let i = 0; i < 50; i++) call.write({ frame: `f${i}` });
242
+ const err = await error;
243
+ assert.strictEqual(err.code, grpc.status.RESOURCE_EXHAUSTED);
244
+ await until(() => fixtureState.floodClosed);
245
+ }
246
+
247
+ @test
248
+ async floodingBytesUnderTheMessageCapFailsWithResourceExhausted() {
249
+ const client = await this.connect();
250
+ fixtureState.floodClosed = false;
251
+ const call = client.Flood(new grpc.Metadata());
252
+ call.on("data", () => {});
253
+ const error = new Promise<any>(resolve => call.on("error", resolve));
254
+ // 3 messages of 600 bytes: under the 5 messages cap, over the 1000 bytes one
255
+ for (let i = 0; i < 3; i++) call.write({ frame: "x".repeat(600) });
256
+ const err = await error;
257
+ assert.strictEqual(err.code, grpc.status.RESOURCE_EXHAUSTED);
258
+ assert.match(err.details, /queued bytes/);
259
+ await until(() => fixtureState.floodClosed);
260
+ }
261
+
262
+ @test
263
+ async contextWaitsForDrainAndStopsOnCancel() {
264
+ const response = new EventEmitter() as any;
265
+ const sent: unknown[] = [];
266
+ const stream = { send: (m: unknown) => (sent.push(m), false) } as any;
267
+ const ctx = new GrpcOperationContext(new HttpContext("localhost", "POST", "/x"), stream, response);
268
+ ctx.setExtension("operationStreaming", true);
269
+ assert.strictEqual(ctx.write({ a: 1, __hidden: 2 }), false);
270
+ assert.deepStrictEqual(sent, [{ a: 1 }]);
271
+ const drained = ctx.drained();
272
+ response.emit("drain");
273
+ await drained;
274
+ ctx.cancel();
275
+ assert.throws(() => ctx.write({ a: 2 }), WebdaError.OperationCancelledError);
276
+ await assert.rejects(() => ctx.drained(), WebdaError.OperationCancelledError);
277
+ }
278
+
279
+ @test
280
+ async anOperationCancellationWithAConnectedClientEndsWithCancelled() {
281
+ const client = await this.connect();
282
+ const err: any = await new Promise(resolve => client.Abort({ text: "x" }, (e: Error) => resolve(e)));
283
+ assert.strictEqual(err.code, grpc.status.CANCELLED);
284
+ }
285
+
286
+ @test
287
+ async aStreamFailingMidCallEndsTheOperation() {
288
+ await this.connect();
289
+ fixtureState.connectClosed = false;
290
+ const http = useService("HttpServer" as any) as any;
291
+ const session = connect(`http://127.0.0.1:${http.server.address().port}`);
292
+ const request = session.request({
293
+ ":method": "POST",
294
+ ":path": "/webda.FixtureService/Connect",
295
+ "content-type": "application/grpc"
296
+ });
297
+ const received: Record<string, any> = {};
298
+ request.on("response", headers => Object.assign(received, headers));
299
+ request.on("trailers", trailers => Object.assign(received, trailers));
300
+ request.on("data", () => {});
301
+ const closed = new Promise(resolve => request.on("end", resolve)); // the response is over; the request side stays open
302
+ request.write(Buffer.from([0, 0, 0, 0, 3, 0x0a, 0x01, 0x61])); // { frame: "a" }
303
+ await new Promise(r => setTimeout(r, 100));
304
+ request.write(Buffer.from([1, 0, 0, 0, 0])); // compressed: the stream fails
305
+ await closed;
306
+ session.close();
307
+ assert.strictEqual(received["grpc-status"], String(grpc.status.UNIMPLEMENTED));
308
+ await until(() => fixtureState.connectClosed);
309
+ }
310
+
311
+ @test
312
+ async drainedLeavesNoListenerAndStopsWhenTheResponseIsOver() {
313
+ const response = new EventEmitter() as any;
314
+ const ctx = new GrpcOperationContext(
315
+ new HttpContext("localhost", "POST", "/x"),
316
+ { send: () => true } as any,
317
+ response
318
+ );
319
+ const baseline = ["drain", "close", "error"].map(name => response.listenerCount(name));
320
+ for (let i = 0; i < 25; i++) {
321
+ const wait = ctx.drained();
322
+ response.emit("drain");
323
+ await wait;
324
+ }
325
+ assert.deepStrictEqual(
326
+ ["drain", "close", "error"].map(name => response.listenerCount(name)),
327
+ baseline
328
+ );
329
+ const closing = ctx.drained();
330
+ response.destroyed = true;
331
+ response.emit("close");
332
+ await assert.rejects(() => closing, WebdaError.OperationCancelledError);
333
+ await assert.rejects(() => ctx.drained(), WebdaError.OperationCancelledError);
334
+ ctx.setExtension("operationStreaming", true);
335
+ assert.throws(() => ctx.write({ a: 1 }), WebdaError.OperationCancelledError);
336
+ }
337
+
338
+ @test
339
+ async drainedRejectsWithACancellationOnResponseError() {
340
+ const response = new EventEmitter() as any;
341
+ const ctx = new GrpcOperationContext(
342
+ new HttpContext("localhost", "POST", "/x"),
343
+ { send: () => true } as any,
344
+ response
345
+ );
346
+ const wait = ctx.drained();
347
+ response.emit("error", new Error("boom"));
348
+ await assert.rejects(() => wait, WebdaError.OperationCancelledError);
349
+ }
350
+
351
+ @test
352
+ messagesAreObjectsWithoutHiddenKeys() {
353
+ assert.deepStrictEqual(toGrpcMessage(3), { value: 3 });
354
+ assert.deepStrictEqual(toGrpcMessage("a"), { value: "a" });
355
+ assert.deepStrictEqual(toGrpcMessage([1]), { value: [1] });
356
+ assert.deepStrictEqual(toGrpcMessage({ a: 1, __b: 2, c: { __d: 1, e: 2 } }), { a: 1, c: { e: 2 } });
357
+ const sent: unknown[] = [];
358
+ const ctx = new GrpcOperationContext(
359
+ new HttpContext("localhost", "POST", "/x"),
360
+ { send: (m: unknown) => (sent.push(m), true) } as any,
361
+ new EventEmitter() as any
362
+ );
363
+ ctx.setExtension("operationStreaming", true);
364
+ ctx.write(7);
365
+ assert.deepStrictEqual(sent, [{ value: 7 }]);
366
+ }
367
+
368
+ @test
369
+ unaryResponsesAreWrapped() {
370
+ const service: any = Object.create(GrpcService.prototype);
371
+ assert.deepStrictEqual(service.unaryResponse(undefined), {});
372
+ assert.deepStrictEqual(service.unaryResponse(""), {});
373
+ assert.deepStrictEqual(service.unaryResponse('{"a":1,"__h":2}'), { a: 1 });
374
+ assert.deepStrictEqual(service.unaryResponse("12"), { value: 12 });
375
+ assert.deepStrictEqual(service.unaryResponse("not json"), { value: "not json" });
376
+ }
377
+ }