@webda/grpc 4.0.0-beta.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,462 @@
1
+ import {
2
+ OperationsTransport,
3
+ OperationsTransportParameters,
4
+ OperationDefinition,
5
+ callOperation,
6
+ OperationContext,
7
+ SimpleOperationContext,
8
+ WebContext,
9
+ useApplication,
10
+ useCore,
11
+ BuildCommand,
12
+ ServiceParameters,
13
+ runWithInstanceStorage,
14
+ useInstanceStorage
15
+ } from "@webda/core";
16
+ import { useLog } from "@webda/workout";
17
+ import * as protoLoader from "@grpc/proto-loader";
18
+ import { existsSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { join, dirname } from "node:path";
20
+ import { GrpcStream, GrpcStatus } from "./grpc-stream.js";
21
+ import { generateProto } from "./proto-generator.js";
22
+ import type { IncomingMessage, ServerResponse } from "node:http";
23
+
24
+ /**
25
+ * Parameters for the gRPC service
26
+ */
27
+ export class GrpcServiceParameters extends OperationsTransportParameters {
28
+ /**
29
+ * Path to the generated .proto file
30
+ * @default ".webda/app.proto"
31
+ */
32
+ protoFile?: string;
33
+
34
+ /**
35
+ * Protobuf package name
36
+ * @default "webda"
37
+ */
38
+ packageName?: string;
39
+
40
+ /**
41
+ * Load and apply default parameter values.
42
+ * @param params - raw configuration object to load into this parameters instance
43
+ * @returns this instance with defaults applied
44
+ */
45
+ load(params: any = {}): this {
46
+ super.load(params);
47
+ this.protoFile ??= ".webda/app.proto";
48
+ this.packageName ??= "webda";
49
+ return this;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * gRPC transport service.
55
+ *
56
+ * Exposes Webda operations as gRPC services over HTTP/2.
57
+ * Hooks into the HttpServer via the `Webda.Init.Http` event to intercept
58
+ * requests with `content-type: application/grpc`.
59
+ *
60
+ * Run `webdac build` to generate the .proto file from operations.
61
+ *
62
+ * @WebdaModda
63
+ */
64
+ export class GrpcService<
65
+ T extends GrpcServiceParameters = GrpcServiceParameters
66
+ > extends OperationsTransport<T> {
67
+ /** Loaded protobuf definitions */
68
+ private definitions: protoLoader.PackageDefinition;
69
+ /** Map of gRPC path → operation ID */
70
+ private rpcToOperation: Map<string, string> = new Map();
71
+ /** Map of gRPC path → method definition */
72
+ private rpcMethods: Map<string, protoLoader.MethodDefinition<any, any>> = new Map();
73
+
74
+ /**
75
+ * Generate a .proto file from the current operation registry.
76
+ *
77
+ * @returns a promise that resolves when the proto file has been written to disk
78
+ */
79
+ @BuildCommand({
80
+ description: "Generate protobuf definition from operations",
81
+ requires: ["rest-domain"]
82
+ })
83
+ async build(): Promise<void> {
84
+ const outPath = this.parameters.protoFile;
85
+ const operations = this.getOperations();
86
+ const app = useApplication();
87
+ const schemas: Record<string, any> = { ...(app.getSchemas?.() || {}) };
88
+ // Fold per-model input schemas into the flat schemas map so that `$ref`
89
+ // lookups like `WebdaSample/Post` resolve to a concrete message shape —
90
+ // `getSchemas()` only returns operation-level schemas, not model ones.
91
+ const models = app.getModels?.() || {};
92
+ for (const modelId of Object.keys(models)) {
93
+ const metadata = (models[modelId] as any)?.Metadata;
94
+ const inputSchema = metadata?.Schemas?.Input;
95
+ if (inputSchema && !schemas[modelId]) {
96
+ schemas[modelId] = inputSchema;
97
+ }
98
+ }
99
+
100
+ const proto = generateProto(operations, schemas, this.parameters.packageName);
101
+
102
+ // Ensure directory exists
103
+ const dir = dirname(outPath);
104
+ if (!existsSync(dir)) {
105
+ mkdirSync(dir, { recursive: true });
106
+ }
107
+
108
+ writeFileSync(outPath, proto, "utf-8");
109
+ this.log("INFO", `Generated proto file: ${outPath}`);
110
+ this.log("INFO", ` ${Object.keys(operations).length} operations → ${this.countServices(operations)} services`);
111
+ }
112
+
113
+ /**
114
+ * Count unique service prefixes in operations
115
+ * @param operations - map of operation IDs to their definitions
116
+ * @returns the number of distinct gRPC service groups derived from operation prefixes
117
+ */
118
+ private countServices(operations: Record<string, any>): number {
119
+ const prefixes = new Set<string>();
120
+ for (const opId of Object.keys(operations)) {
121
+ const dot = opId.indexOf(".");
122
+ prefixes.add(dot > 0 ? opId.substring(0, dot) : "Default");
123
+ }
124
+ return prefixes.size;
125
+ }
126
+
127
+ /**
128
+ * Initialize the gRPC transport: load proto, build RPC map, hook into HTTP/2.
129
+ * @returns a promise resolving to this service instance once initialization is complete
130
+ */
131
+ async init(): Promise<this> {
132
+ await super.init();
133
+
134
+ // Load proto file if it exists
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
+ }
158
+
159
+ // Plug the gRPC dispatcher into every HttpServer instance in the app.
160
+ // Serving both REST (HTTP/1.1 or TLS+ALPN) and plaintext gRPC (h2c) means
161
+ // configuring two HttpServer services with different protocols; gRPC
162
+ // doesn't care which one — it claims any `application/grpc` request.
163
+ //
164
+ // AsyncLocalStorage doesn't reliably cross HTTP/2 data events, so capture
165
+ // the InstanceStorage here and re-enter it per request.
166
+ const capturedStorage = useInstanceStorage();
167
+ const servers = Object.values(useCore().getServices()).filter(
168
+ s => typeof (s as any).registerRequestInterceptor === "function"
169
+ );
170
+ if (servers.length === 0) {
171
+ useLog("WARN", "No HttpServer found — gRPC cannot register its interceptor");
172
+ return this;
173
+ }
174
+ const interceptor = (req: IncomingMessage, res: ServerResponse): boolean => {
175
+ const contentType = (req.headers["content-type"] as string) || "";
176
+ if (!contentType.startsWith("application/grpc")) return false;
177
+ runWithInstanceStorage(capturedStorage, () => {
178
+ this.handleGrpcRequest(req as any, res as any).catch(err => {
179
+ useLog("ERROR", "[GrpcService] handler failed:", err);
180
+ });
181
+ });
182
+ return true;
183
+ };
184
+ for (const server of servers) {
185
+ (server as any).registerRequestInterceptor(interceptor);
186
+ }
187
+
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * Build the mapping from gRPC path → operation ID using the loaded proto definitions.
193
+ */
194
+ private buildRpcMap(): void {
195
+ if (!this.definitions) return;
196
+
197
+ const operations = this.getOperations();
198
+ const pkg = this.parameters.packageName;
199
+
200
+ // `protoLoader.loadSync` returns a flat map where service entries are plain
201
+ // objects keyed by method name (each method has a `.path`), mixed with
202
+ // top-level message types. Walk both levels so we catch every method.
203
+ const visitMethod = (methodDef: any) => {
204
+ if (!methodDef?.path) return;
205
+ const parts = (methodDef.path as string).split("/").filter(Boolean);
206
+ if (parts.length !== 2) return;
207
+ const [fullService, method] = parts;
208
+ const serviceName = fullService.replace(`${pkg}.`, "").replace(/Service$/, "");
209
+ const opId = `${serviceName}.${method}`;
210
+ if (operations[opId]) {
211
+ this.rpcToOperation.set(methodDef.path, opId);
212
+ this.rpcMethods.set(methodDef.path, methodDef);
213
+ }
214
+ };
215
+ for (const def of Object.values(this.definitions)) {
216
+ if (typeof def !== "object" || def === null) continue;
217
+ if ((def as any).path) {
218
+ visitMethod(def);
219
+ } else {
220
+ // Service object — iterate its method members
221
+ for (const inner of Object.values(def)) {
222
+ if (typeof inner === "object" && (inner as any)?.path) visitMethod(inner);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Handle an incoming gRPC request.
230
+ *
231
+ * Called by the HttpServer when content-type is application/grpc.
232
+ *
233
+ * @param req - HTTP/2 request carrying the gRPC method path and framed message body
234
+ * @param res - HTTP/2 response used to write gRPC frames and trailers
235
+ * @returns a promise that resolves when the response has been fully sent
236
+ */
237
+ async handleGrpcRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
238
+ const grpcPath = req.url;
239
+ const opId = this.rpcToOperation.get(grpcPath);
240
+ const methodDef = this.rpcMethods.get(grpcPath);
241
+
242
+ if (!opId || !methodDef) {
243
+ res.writeHead(200, {
244
+ "content-type": "application/grpc",
245
+ "grpc-status": String(GrpcStatus.UNIMPLEMENTED),
246
+ "grpc-message": encodeURIComponent(`Method not found: ${grpcPath}`)
247
+ });
248
+ res.end();
249
+ return;
250
+ }
251
+
252
+ const stream = new GrpcStream(req, res, methodDef);
253
+ const operation = this.getOperations()[opId];
254
+ const grpc = operation?.grpc;
255
+ const streaming = (typeof grpc === "object" && grpc ? grpc.streaming : undefined) || "none";
256
+
257
+ try {
258
+ if (streaming === "bidi" || streaming === "client") {
259
+ // Client/bidi streaming — collect messages and process
260
+ await this.handleStreamingRequest(stream, opId, streaming);
261
+ } else if (streaming === "server") {
262
+ // Server streaming — single request, stream responses
263
+ await this.handleServerStreaming(stream, opId, methodDef);
264
+ } else {
265
+ // Unary — single request, single response
266
+ await this.handleUnary(stream, opId, methodDef);
267
+ }
268
+ } catch (err) {
269
+ const status = this.errorToGrpcStatus(err);
270
+ stream.sendError(status, err.message || "Internal error");
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Handle a unary gRPC call (single request → single response).
276
+ * @param stream - the active GrpcStream for this request
277
+ * @param opId - the Webda operation ID to invoke
278
+ * @param methodDef - the protobuf method definition with serializer functions
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
340
+ */
341
+ private handleServerStreaming(
342
+ stream: GrpcStream,
343
+ opId: string,
344
+ methodDef: protoLoader.MethodDefinition<any, any>
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
+ });
376
+ }
377
+
378
+ /**
379
+ * Handle client or bidirectional streaming.
380
+ * @param stream - the active GrpcStream for this request
381
+ * @param opId - the Webda operation ID to invoke
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
+ */
385
+ private handleStreamingRequest(
386
+ stream: GrpcStream,
387
+ opId: string,
388
+ streaming: string
389
+ ): Promise<void> {
390
+ return new Promise<void>((resolve) => {
391
+ const messages: any[] = [];
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
+ });
426
+ }
427
+
428
+ /**
429
+ * Map a Webda error to a gRPC status code.
430
+ * @param err - the error thrown by a Webda operation, expected to have a getResponseCode method
431
+ * @returns the corresponding gRPC status code integer
432
+ */
433
+ private errorToGrpcStatus(err: any): number {
434
+ const code = err?.getResponseCode?.();
435
+ switch (code) {
436
+ case 400:
437
+ return GrpcStatus.INVALID_ARGUMENT;
438
+ case 401:
439
+ return GrpcStatus.UNAUTHENTICATED;
440
+ case 403:
441
+ return GrpcStatus.PERMISSION_DENIED;
442
+ case 404:
443
+ return GrpcStatus.NOT_FOUND;
444
+ case 409:
445
+ return GrpcStatus.ALREADY_EXISTS;
446
+ case 429:
447
+ return GrpcStatus.RESOURCE_EXHAUSTED;
448
+ default:
449
+ return GrpcStatus.INTERNAL;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * No-op — gRPC operations are exposed via the proto definition, not individual routes.
455
+ * @param _operationId - the operation ID (unused; routing is handled by the proto definition)
456
+ * @param _definition - the operation definition (unused)
457
+ * @returns void
458
+ */
459
+ exposeOperation(_operationId: string, _definition: OperationDefinition): void {
460
+ // gRPC doesn't register routes individually — handled by proto definition
461
+ }
462
+ }