@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
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AsyncQueue,
|
|
3
|
+
BuildCommand,
|
|
4
|
+
HttpContext,
|
|
5
|
+
OperationDefinition,
|
|
2
6
|
OperationsTransport,
|
|
3
7
|
OperationsTransportParameters,
|
|
4
|
-
|
|
8
|
+
WebdaError,
|
|
5
9
|
callOperation,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
emitCoreEvent,
|
|
11
|
+
getOperationStreaming,
|
|
12
|
+
runWithContext,
|
|
13
|
+
runWithInstanceStorage,
|
|
9
14
|
useApplication,
|
|
10
15
|
useCore,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
runWithInstanceStorage,
|
|
14
|
-
useInstanceStorage
|
|
16
|
+
useInstanceStorage,
|
|
17
|
+
useRouter
|
|
15
18
|
} from "@webda/core";
|
|
16
19
|
import { useLog } from "@webda/workout";
|
|
17
20
|
import * as protoLoader from "@grpc/proto-loader";
|
|
18
21
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
22
|
import { join, dirname } from "node:path";
|
|
23
|
+
import { GrpcOperationContext, toGrpcMessage } from "./grpc-context.js";
|
|
20
24
|
import { GrpcStream, GrpcStatus } from "./grpc-stream.js";
|
|
21
25
|
import { generateProto } from "./proto-generator.js";
|
|
22
26
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
@@ -37,6 +41,22 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
|
|
|
37
41
|
*/
|
|
38
42
|
packageName?: string;
|
|
39
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Most unconsumed messages a client or bidirectional operation may have queued; above it the call fails with
|
|
46
|
+
* RESOURCE_EXHAUSTED
|
|
47
|
+
*
|
|
48
|
+
* @default 1000
|
|
49
|
+
*/
|
|
50
|
+
maxQueuedMessages?: number;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Most bytes of unconsumed messages a client or bidirectional operation may have queued; above it the call fails
|
|
54
|
+
* with RESOURCE_EXHAUSTED
|
|
55
|
+
*
|
|
56
|
+
* @default 16777216
|
|
57
|
+
*/
|
|
58
|
+
maxQueuedBytes?: number;
|
|
59
|
+
|
|
40
60
|
/**
|
|
41
61
|
* Load and apply default parameter values.
|
|
42
62
|
* @param params - raw configuration object to load into this parameters instance
|
|
@@ -46,6 +66,8 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
|
|
|
46
66
|
super.load(params);
|
|
47
67
|
this.protoFile ??= ".webda/app.proto";
|
|
48
68
|
this.packageName ??= "webda";
|
|
69
|
+
this.maxQueuedMessages ??= 1000;
|
|
70
|
+
this.maxQueuedBytes ??= 16 * 1024 * 1024;
|
|
49
71
|
return this;
|
|
50
72
|
}
|
|
51
73
|
}
|
|
@@ -61,9 +83,7 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
|
|
|
61
83
|
*
|
|
62
84
|
* @WebdaModda
|
|
63
85
|
*/
|
|
64
|
-
export class GrpcService<
|
|
65
|
-
T extends GrpcServiceParameters = GrpcServiceParameters
|
|
66
|
-
> extends OperationsTransport<T> {
|
|
86
|
+
export class GrpcService<T extends GrpcServiceParameters = GrpcServiceParameters> extends OperationsTransport<T> {
|
|
67
87
|
/** Loaded protobuf definitions */
|
|
68
88
|
private definitions: protoLoader.PackageDefinition;
|
|
69
89
|
/** Map of gRPC path → operation ID */
|
|
@@ -71,6 +91,14 @@ export class GrpcService<
|
|
|
71
91
|
/** Map of gRPC path → method definition */
|
|
72
92
|
private rpcMethods: Map<string, protoLoader.MethodDefinition<any, any>> = new Map();
|
|
73
93
|
|
|
94
|
+
/**
|
|
95
|
+
* @param params - raw service parameters
|
|
96
|
+
* @returns the loaded parameters (with the operations include/exclude filter)
|
|
97
|
+
*/
|
|
98
|
+
static createConfiguration(params: any = {}): GrpcServiceParameters {
|
|
99
|
+
return new GrpcServiceParameters().load(params);
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
/**
|
|
75
103
|
* Generate a .proto file from the current operation registry.
|
|
76
104
|
*
|
|
@@ -124,6 +152,34 @@ export class GrpcService<
|
|
|
124
152
|
return prefixes.size;
|
|
125
153
|
}
|
|
126
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Load the .proto file and map its RPC methods to operations (also callable after registering operations late)
|
|
157
|
+
*/
|
|
158
|
+
loadDefinitions(): void {
|
|
159
|
+
if (!existsSync(this.parameters.protoFile)) {
|
|
160
|
+
useLog("WARN", `Proto file not found: ${this.parameters.protoFile}. Run 'webdac build' to generate it.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
this.definitions = protoLoader.loadSync(this.parameters.protoFile, {
|
|
165
|
+
keepCase: true,
|
|
166
|
+
longs: String,
|
|
167
|
+
enums: String,
|
|
168
|
+
defaults: true,
|
|
169
|
+
oneofs: true
|
|
170
|
+
});
|
|
171
|
+
this.rpcToOperation.clear();
|
|
172
|
+
this.rpcMethods.clear();
|
|
173
|
+
this.buildRpcMap();
|
|
174
|
+
useLog(
|
|
175
|
+
"INFO",
|
|
176
|
+
`Loaded gRPC definitions from ${this.parameters.protoFile} — ${this.rpcToOperation.size} RPC methods mapped`
|
|
177
|
+
);
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
useLog("WARN", `Failed to load proto file: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
127
183
|
/**
|
|
128
184
|
* Initialize the gRPC transport: load proto, build RPC map, hook into HTTP/2.
|
|
129
185
|
* @returns a promise resolving to this service instance once initialization is complete
|
|
@@ -131,30 +187,7 @@ export class GrpcService<
|
|
|
131
187
|
async init(): Promise<this> {
|
|
132
188
|
await super.init();
|
|
133
189
|
|
|
134
|
-
|
|
135
|
-
if (existsSync(this.parameters.protoFile)) {
|
|
136
|
-
try {
|
|
137
|
-
this.definitions = protoLoader.loadSync(this.parameters.protoFile, {
|
|
138
|
-
keepCase: true,
|
|
139
|
-
longs: String,
|
|
140
|
-
enums: String,
|
|
141
|
-
defaults: true,
|
|
142
|
-
oneofs: true
|
|
143
|
-
});
|
|
144
|
-
this.buildRpcMap();
|
|
145
|
-
useLog(
|
|
146
|
-
"INFO",
|
|
147
|
-
`Loaded gRPC definitions from ${this.parameters.protoFile} — ${this.rpcToOperation.size} RPC methods mapped`
|
|
148
|
-
);
|
|
149
|
-
} catch (err: any) {
|
|
150
|
-
useLog("WARN", `Failed to load proto file: ${err.message}`);
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
153
|
-
useLog(
|
|
154
|
-
"WARN",
|
|
155
|
-
`Proto file not found: ${this.parameters.protoFile}. Run 'webdac build' to generate it.`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
190
|
+
this.loadDefinitions();
|
|
158
191
|
|
|
159
192
|
// Plug the gRPC dispatcher into every HttpServer instance in the app.
|
|
160
193
|
// Serving both REST (HTTP/1.1 or TLS+ALPN) and plaintext gRPC (h2c) means
|
|
@@ -226,7 +259,21 @@ export class GrpcService<
|
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
/**
|
|
229
|
-
*
|
|
262
|
+
* The request line and metadata of a gRPC call as an HttpContext (HTTP/2 pseudo-headers removed)
|
|
263
|
+
* @param req - the HTTP/2 request
|
|
264
|
+
* @returns the http context
|
|
265
|
+
*/
|
|
266
|
+
private httpContextOf(req: IncomingMessage): HttpContext {
|
|
267
|
+
const raw = req.headers as Record<string, string | string[]>;
|
|
268
|
+
const authority = (raw[":authority"] as string) || (raw.host as string) || "localhost";
|
|
269
|
+
const [hostname, port] = authority.split(":");
|
|
270
|
+
const headers = Object.fromEntries(Object.entries(raw).filter(([key]) => !key.startsWith(":")));
|
|
271
|
+
return new HttpContext(hostname, "POST", req.url || "/", "http", port || "80", headers as any);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Handle an incoming gRPC request: one path for the four modes. Incoming messages feed the operation (as its
|
|
276
|
+
* input, or as the stream in `operationInputStream`), streamed output is sent as it is produced.
|
|
230
277
|
*
|
|
231
278
|
* Called by the HttpServer when content-type is application/grpc.
|
|
232
279
|
*
|
|
@@ -250,179 +297,101 @@ export class GrpcService<
|
|
|
250
297
|
}
|
|
251
298
|
|
|
252
299
|
const stream = new GrpcStream(req, res, methodDef);
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
300
|
+
const streaming = getOperationStreaming(this.getOperations()[opId]);
|
|
301
|
+
const ctx = new GrpcOperationContext(this.httpContextOf(req), stream, res);
|
|
302
|
+
const input = new AsyncQueue<unknown>();
|
|
303
|
+
// Handlers first: data may arrive while the session loads
|
|
304
|
+
stream.onMessage((message, size) => {
|
|
305
|
+
input.push(this.cleanMessage(message), size);
|
|
306
|
+
const bytes = input.pendingBytes > this.parameters.maxQueuedBytes;
|
|
307
|
+
if ((bytes || input.pending > this.parameters.maxQueuedMessages) && !finished()) {
|
|
308
|
+
// The operation does not keep up: stop it rather than buffering without limit
|
|
309
|
+
stream.sendError(GrpcStatus.RESOURCE_EXHAUSTED, bytes ? "Too many queued bytes" : "Too many queued messages");
|
|
310
|
+
ctx.cancel();
|
|
311
|
+
input.end(true);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
stream.onEnd(() => input.end());
|
|
315
|
+
stream.onCancel(() => {
|
|
316
|
+
ctx.cancel();
|
|
317
|
+
input.end();
|
|
318
|
+
});
|
|
319
|
+
// Whatever ends the response (client gone, or the stream failing on a bad frame): stop the operation
|
|
320
|
+
res.on("close", () => {
|
|
321
|
+
ctx.cancel();
|
|
322
|
+
input.end();
|
|
323
|
+
});
|
|
324
|
+
// The stream may fail itself (bad frame, ...): it then ends the response, nothing more must be written
|
|
325
|
+
const finished = () => ctx.isCancelled || (res as any).writableEnded;
|
|
256
326
|
|
|
257
327
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
328
|
+
await ctx.init();
|
|
329
|
+
const allowed = await runWithContext(ctx, async () => {
|
|
330
|
+
try {
|
|
331
|
+
emitCoreEvent("Webda.Request", { context: ctx });
|
|
332
|
+
} catch {
|
|
333
|
+
// listener error
|
|
334
|
+
}
|
|
335
|
+
return useRouter().checkRequest(ctx);
|
|
336
|
+
});
|
|
337
|
+
if (!allowed) {
|
|
338
|
+
if (!finished()) stream.sendError(GrpcStatus.PERMISSION_DENIED, "Forbidden");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (streaming === "client" || streaming === "bidi") {
|
|
342
|
+
ctx.setExtension("operationInputStream", input);
|
|
343
|
+
} else {
|
|
344
|
+
const first = await input.next();
|
|
345
|
+
if (finished()) return;
|
|
346
|
+
ctx.setMessage(first.done ? {} : first.value);
|
|
347
|
+
}
|
|
348
|
+
if (finished()) return;
|
|
349
|
+
await callOperation(ctx, opId);
|
|
350
|
+
if (finished()) return;
|
|
351
|
+
if (ctx.getExtension("operationStreaming")) {
|
|
352
|
+
stream.end(GrpcStatus.OK);
|
|
264
353
|
} else {
|
|
265
|
-
|
|
266
|
-
await this.handleUnary(stream, opId, methodDef);
|
|
354
|
+
stream.sendUnary(this.unaryResponse(ctx.getOutput()));
|
|
267
355
|
}
|
|
268
|
-
} catch (err) {
|
|
269
|
-
|
|
270
|
-
|
|
356
|
+
} catch (err: any) {
|
|
357
|
+
if (finished()) return;
|
|
358
|
+
if (err instanceof WebdaError.OperationCancelledError) {
|
|
359
|
+
// Raised by the operation itself while the client is still there
|
|
360
|
+
stream.sendError(GrpcStatus.CANCELLED, err.message || "Operation cancelled");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!(err?.getResponseCode?.() < 500)) useLog("ERROR", `[gRPC ${opId}] handler threw:`, err);
|
|
364
|
+
// Only client errors (4xx) carry their message: anything else may leak internals
|
|
365
|
+
const code = err?.getResponseCode?.();
|
|
366
|
+
const visible = typeof code === "number" && code >= 400 && code < 500;
|
|
367
|
+
stream.sendError(this.errorToGrpcStatus(err), visible ? err.message || "Bad request" : "Internal server error");
|
|
271
368
|
}
|
|
272
369
|
}
|
|
273
370
|
|
|
274
371
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* @param
|
|
278
|
-
* @
|
|
279
|
-
* @returns a promise that resolves when the unary response has been sent
|
|
280
|
-
*/
|
|
281
|
-
private handleUnary(
|
|
282
|
-
stream: GrpcStream,
|
|
283
|
-
opId: string,
|
|
284
|
-
methodDef: protoLoader.MethodDefinition<any, any>
|
|
285
|
-
): Promise<void> {
|
|
286
|
-
// AsyncLocalStorage doesn't always propagate through the HTTP/2 'data'
|
|
287
|
-
// event that drives GrpcStream.onMessage, so capture the storage here and
|
|
288
|
-
// re-enter it inside the callback.
|
|
289
|
-
const instanceStorage = useInstanceStorage();
|
|
290
|
-
return new Promise<void>((resolve, reject) => {
|
|
291
|
-
stream.onMessage(message => {
|
|
292
|
-
runWithInstanceStorage(instanceStorage, async () => {
|
|
293
|
-
try {
|
|
294
|
-
const ctx = new SimpleOperationContext();
|
|
295
|
-
await ctx.init();
|
|
296
|
-
// Proto3 `optional` fields deserialize with an extra `_field: "field"`
|
|
297
|
-
// marker (synthetic oneof tracking). Drop those so the payload matches
|
|
298
|
-
// the backing JSON schema which forbids additional properties.
|
|
299
|
-
const cleaned =
|
|
300
|
-
typeof message === "object" && message !== null && !Array.isArray(message)
|
|
301
|
-
? Object.fromEntries(Object.entries(message).filter(([k]) => !k.startsWith("_")))
|
|
302
|
-
: message;
|
|
303
|
-
ctx.setInput(Buffer.from(JSON.stringify(cleaned)));
|
|
304
|
-
await callOperation(ctx, opId);
|
|
305
|
-
const output = ctx.getOutput();
|
|
306
|
-
// Operation return values may be plain strings/numbers (e.g. version
|
|
307
|
-
// strings, publish ids). Wrap them in a `{value: ...}` object so the
|
|
308
|
-
// proto response is always a valid message shape.
|
|
309
|
-
let response: any = {};
|
|
310
|
-
if (output !== undefined && output !== null && output !== "") {
|
|
311
|
-
try {
|
|
312
|
-
response = JSON.parse(output);
|
|
313
|
-
if (typeof response !== "object" || response === null) {
|
|
314
|
-
response = { value: response };
|
|
315
|
-
}
|
|
316
|
-
} catch {
|
|
317
|
-
response = { value: output };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
stream.sendUnary(response);
|
|
321
|
-
resolve();
|
|
322
|
-
} catch (err: any) {
|
|
323
|
-
useLog("ERROR", `[gRPC ${opId}] handler threw:`, err);
|
|
324
|
-
const status = this.errorToGrpcStatus(err);
|
|
325
|
-
stream.sendError(status, err.message);
|
|
326
|
-
resolve(); // Don't reject — error was sent via gRPC status
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Handle server streaming (single request → stream of responses).
|
|
335
|
-
* Maps to AsyncGenerator operations.
|
|
336
|
-
* @param stream - the active GrpcStream for this request
|
|
337
|
-
* @param opId - the Webda operation ID to invoke
|
|
338
|
-
* @param methodDef - the protobuf method definition with serializer functions
|
|
339
|
-
* @returns a promise that resolves when all response frames have been sent
|
|
372
|
+
* Proto3 `optional` fields deserialize with an extra `_field: "field"` marker (synthetic oneof tracking): drop
|
|
373
|
+
* those so the payload matches the backing JSON schema
|
|
374
|
+
* @param message - decoded message
|
|
375
|
+
* @returns the message without markers
|
|
340
376
|
*/
|
|
341
|
-
private
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
): Promise<void> {
|
|
346
|
-
return new Promise<void>((resolve, reject) => {
|
|
347
|
-
stream.onMessage(async message => {
|
|
348
|
-
try {
|
|
349
|
-
const ctx = new SimpleOperationContext();
|
|
350
|
-
await ctx.init();
|
|
351
|
-
ctx.setInput(Buffer.from(JSON.stringify(message)));
|
|
352
|
-
await callOperation(ctx, opId);
|
|
353
|
-
// callOperation writes chunks to ctx for AsyncGenerators
|
|
354
|
-
// For now, send the full output as a single response
|
|
355
|
-
const output = ctx.getOutput();
|
|
356
|
-
if (output) {
|
|
357
|
-
// Try to parse as NDJSON (multiple JSON objects)
|
|
358
|
-
const lines = output.split("\n").filter(Boolean);
|
|
359
|
-
for (const line of lines) {
|
|
360
|
-
try {
|
|
361
|
-
stream.send(JSON.parse(line));
|
|
362
|
-
} catch {
|
|
363
|
-
stream.send({ data: line });
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
stream.end(GrpcStatus.OK);
|
|
368
|
-
resolve();
|
|
369
|
-
} catch (err: any) {
|
|
370
|
-
const status = this.errorToGrpcStatus(err);
|
|
371
|
-
stream.sendError(status, err.message);
|
|
372
|
-
resolve();
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
});
|
|
377
|
+
private cleanMessage(message: unknown): unknown {
|
|
378
|
+
return typeof message === "object" && message !== null && !Array.isArray(message)
|
|
379
|
+
? Object.fromEntries(Object.entries(message).filter(([key]) => !key.startsWith("_")))
|
|
380
|
+
: message;
|
|
376
381
|
}
|
|
377
382
|
|
|
378
383
|
/**
|
|
379
|
-
*
|
|
380
|
-
* @param
|
|
381
|
-
* @
|
|
382
|
-
* @param streaming - streaming mode, either "client" or "bidi"
|
|
383
|
-
* @returns a promise that resolves when the operation completes and the response is sent
|
|
384
|
+
* The unary response for an operation output (a plain string/number is wrapped as `{ value }`)
|
|
385
|
+
* @param output - the context output
|
|
386
|
+
* @returns the response message
|
|
384
387
|
*/
|
|
385
|
-
private
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
stream.onMessage(message => {
|
|
394
|
-
messages.push(message);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
stream.onEnd(async () => {
|
|
398
|
-
try {
|
|
399
|
-
// For client streaming: process all collected messages
|
|
400
|
-
const ctx = new SimpleOperationContext();
|
|
401
|
-
await ctx.init();
|
|
402
|
-
ctx.setInput(Buffer.from(JSON.stringify(messages)));
|
|
403
|
-
await callOperation(ctx, opId);
|
|
404
|
-
const output = ctx.getOutput();
|
|
405
|
-
const response = output ? JSON.parse(output) : {};
|
|
406
|
-
|
|
407
|
-
if (streaming === "bidi") {
|
|
408
|
-
// Bidi: send response for each input message
|
|
409
|
-
stream.send(response);
|
|
410
|
-
} else {
|
|
411
|
-
// Client streaming: single response
|
|
412
|
-
stream.sendUnary(response);
|
|
413
|
-
}
|
|
414
|
-
resolve();
|
|
415
|
-
} catch (err: any) {
|
|
416
|
-
const status = this.errorToGrpcStatus(err);
|
|
417
|
-
stream.sendError(status, err.message);
|
|
418
|
-
resolve();
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
stream.onCancel(() => {
|
|
423
|
-
resolve();
|
|
424
|
-
});
|
|
425
|
-
});
|
|
388
|
+
private unaryResponse(output: string | undefined): Record<string, unknown> {
|
|
389
|
+
if (output === undefined || output === null || output === "") return {};
|
|
390
|
+
try {
|
|
391
|
+
return toGrpcMessage(JSON.parse(output));
|
|
392
|
+
} catch {
|
|
393
|
+
return { value: output };
|
|
394
|
+
}
|
|
426
395
|
}
|
|
427
396
|
|
|
428
397
|
/**
|