@webda/grpc 4.0.0-beta.6 → 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.
- package/.webda/cache +4 -4
- package/CHANGELOG.md +23 -0
- package/README.md +24 -10
- package/lib/grpc-context.d.ts +55 -0
- package/lib/grpc-context.d.ts.map +1 -0
- package/lib/grpc-context.js +83 -0
- package/lib/grpc-context.js.map +1 -0
- package/lib/grpc-stream.d.ts +13 -4
- package/lib/grpc-stream.d.ts.map +1 -1
- package/lib/grpc-stream.js +67 -23
- package/lib/grpc-stream.js.map +1 -1
- package/lib/grpcservice.service.d.ts +41 -23
- package/lib/grpcservice.service.d.ts.map +1 -1
- package/lib/grpcservice.service.js +155 -169
- package/lib/grpcservice.service.js.map +1 -1
- package/lib/proto-generator.js +1 -1
- package/lib/proto-generator.js.map +1 -1
- package/package.json +8 -5
- package/src/connect.e2e.spec.ts +114 -0
- package/src/grpc-context.ts +93 -0
- package/src/grpc-live.spec.ts +377 -0
- package/src/grpc-stream.spec.ts +122 -1
- package/src/grpc-stream.ts +63 -25
- package/src/grpcservice.service.ts +165 -196
- package/src/grpcservice.spec.ts +29 -442
- package/src/proto-generator.spec.ts +17 -0
- package/src/proto-generator.ts +1 -1
- package/src/query-statements.spec.ts +39 -13
- package/test/fixture.ts +166 -0
- package/webda.module.json +10 -0
|
@@ -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
|
+
}
|