@trevrpc/trevrpc-js 1.3.2 → 1.5.0

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/src/client.js CHANGED
@@ -1,12 +1,22 @@
1
1
  import { Call, EarlyOk, defaultCallOptions, mergeCallOptions } from "./call.js";
2
2
  import { FrameType, marshalMessage, unmarshalMessage } from "./framing.js";
3
+ import { int64Converter } from "./int64.js";
4
+ import { interceptCall, interceptorList } from "./interceptor.js";
3
5
  import { validateMetadata } from "./metadata.js";
4
6
  import { Code, cancelled, internal, statusError } from "./status.js";
5
7
  import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
6
8
 
7
- /** Creates a service client from a generated service descriptor. */
9
+ /**
10
+ * Creates a service client from a generated service descriptor. Its calls run the interceptors of
11
+ * the transport, then those of `options.interceptors`.
12
+ */
8
13
  export function createServiceClient(transport, service, root, options = {}) {
9
- const defaults = mergeCallOptions(defaultCallOptions(), options);
14
+ const { interceptors: clientInterceptors, ...callOptions } = options;
15
+ const defaults = mergeCallOptions(defaultCallOptions(), callOptions);
16
+ const interceptors = [
17
+ ...interceptorList(transport.interceptors, "transport interceptors"),
18
+ ...interceptorList(clientInterceptors, "interceptors"),
19
+ ];
10
20
  const client = {};
11
21
 
12
22
  for (const [jsName, method] of Object.entries(service.methods)) {
@@ -17,6 +27,8 @@ export function createServiceClient(transport, service, root, options = {}) {
17
27
  path: `/${service.fullName}/${method.name}`,
18
28
  requestType,
19
29
  responseType,
30
+ // Converts the 64-bit integers of decoded responses, or undefined when they need nothing.
31
+ convertResponse: int64Converter(responseType),
20
32
  // Full protobuf names, which pick the compression dictionaries.
21
33
  types: { request: fullName(requestType), response: fullName(responseType) },
22
34
  datagrams: datagramUse(method),
@@ -24,31 +36,61 @@ export function createServiceClient(transport, service, root, options = {}) {
24
36
  compress: plainCompressionUse(method),
25
37
  };
26
38
  const resolve = (override) => mergeCallOptions(defaults, override);
27
-
28
- switch (method.kind) {
29
- case "unary":
30
- client[jsName] = async (request, override) =>
31
- unaryCall(transport, rpc, request, resolve(override));
32
- break;
33
- case "serverStreaming":
34
- client[jsName] = (request, override) =>
35
- serverStreamingCall(transport, rpc, request, resolve(override));
36
- break;
37
- case "clientStreaming":
38
- client[jsName] = (override) => clientStreamingCall(transport, rpc, resolve(override));
39
- break;
40
- case "bidirectionalStreaming":
41
- client[jsName] = (override) =>
42
- bidirectionalStreamingCall(transport, rpc, resolve(override));
43
- break;
44
- default:
45
- throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
39
+ const shape = MethodShapes[method.kind];
40
+ if (shape === undefined) {
41
+ throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
46
42
  }
43
+ const info = {
44
+ method: rpc.path,
45
+ service: service.fullName,
46
+ name: method.name,
47
+ kind: method.kind,
48
+ };
49
+ const call =
50
+ interceptors.length === 0
51
+ ? async (request, override) => shape.start(transport, rpc, request, resolve(override))
52
+ : async (request, override) =>
53
+ interceptCall(
54
+ interceptors,
55
+ info,
56
+ resolve(override),
57
+ (options) => shape.start(transport, rpc, request, options),
58
+ shape.ended,
59
+ );
60
+ client[jsName] = shape.takesRequest ? call : (override) => call(undefined, override);
47
61
  }
48
62
 
49
63
  return client;
50
64
  }
51
65
 
66
+ /**
67
+ * How each kind of RPC is called: whether its client method takes a request message, how it
68
+ * starts, and, for streaming calls, what settles when the call ends after it started.
69
+ */
70
+ const MethodShapes = Object.freeze({
71
+ __proto__: null,
72
+ unary: {
73
+ takesRequest: true,
74
+ start: unaryCall,
75
+ },
76
+ serverStreaming: {
77
+ takesRequest: true,
78
+ start: serverStreamingCall,
79
+ ended: (stream) => stream.status,
80
+ },
81
+ clientStreaming: {
82
+ takesRequest: false,
83
+ start: (transport, rpc, _request, options) => clientStreamingCall(transport, rpc, options),
84
+ ended: (call) => call.status,
85
+ },
86
+ bidirectionalStreaming: {
87
+ takesRequest: false,
88
+ start: (transport, rpc, _request, options) =>
89
+ bidirectionalStreamingCall(transport, rpc, options),
90
+ ended: (call) => call.status,
91
+ },
92
+ });
93
+
52
94
  /** Calls a unary RPC and returns the decoded response. */
53
95
  export async function unaryCall(transport, rpc, request, options) {
54
96
  const body = marshalMessage(rpc.requestType, request);
@@ -376,7 +418,8 @@ export class BidirectionalStreamingCall {
376
418
  const item = await this.#take();
377
419
  if (item.payload !== undefined) {
378
420
  try {
379
- return unmarshalMessage(this.#rpc.responseType, item.payload);
421
+ const message = unmarshalMessage(this.#rpc.responseType, item.payload);
422
+ return this.#rpc.convertResponse?.(message) ?? message;
380
423
  } catch (error) {
381
424
  throw this.#settleFailure(this.#call.fail(error));
382
425
  }
@@ -678,6 +721,11 @@ export class ClientStreamingCall {
678
721
  this.#call = call;
679
722
  }
680
723
 
724
+ /** The final status, or the error that ended the call. */
725
+ get status() {
726
+ return this.#call.status;
727
+ }
728
+
681
729
  /** Sends one request message. */
682
730
  send(message) {
683
731
  return this.#call.send(message);
@@ -8,12 +8,12 @@
8
8
  // confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER.
9
9
  import { normalizeMetadata } from "./metadata.js";
10
10
 
11
- /** The property of a transport that holds its ConnectionMetadataSource. */
12
- export const ConnectionMetadataKey = Symbol("trevrpc.connectionMetadata");
13
-
14
- /** The connection metadata of a transport, or undefined without a source. */
11
+ /**
12
+ * The connection metadata of a transport: a Channel's own, which a transport that wraps one
13
+ * forwards; undefined without a source.
14
+ */
15
15
  export function transportConnectionMetadata(transport) {
16
- return transport[ConnectionMetadataKey];
16
+ return transport.connectionMetadata ?? undefined;
17
17
  }
18
18
 
19
19
  /** A request that carries no connection metadata. */
package/src/generator.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import protobuf from "protobufjs";
2
2
 
3
+ import { Int64Representations } from "./int64.js";
4
+
3
5
  const DESCRIPTOR_PROTO = String.raw`
4
6
  syntax = "proto2";
5
7
 
@@ -107,6 +109,12 @@ message TrevrpcMethodOptions {
107
109
  }
108
110
 
109
111
  message FieldOptions {
112
+ enum JSType {
113
+ JS_NORMAL = 0;
114
+ JS_STRING = 1;
115
+ JS_NUMBER = 2;
116
+ }
117
+ optional JSType jstype = 6;
110
118
  optional bool debug_redact = 16;
111
119
  }
112
120
 
@@ -195,6 +203,24 @@ const GeneratedTypeImports = [
195
203
  "ResponseStream",
196
204
  ];
197
205
 
206
+ /**
207
+ * Well-known types whose declarations the runtime exports, so that generated files share them
208
+ * instead of declaring their own: the full name and whether it holds 64-bit integers, which the
209
+ * runtime declares as bigint, so it is shared only when that is the representation.
210
+ */
211
+ const SharedWellKnownTypes = new Map([
212
+ ["google.protobuf.Timestamp", { int64: true }],
213
+ ["google.protobuf.Duration", { int64: true }],
214
+ ["google.protobuf.Empty", { int64: false }],
215
+ ["google.protobuf.Struct", { int64: false }],
216
+ ["google.protobuf.Value", { int64: false }],
217
+ ["google.protobuf.ListValue", { int64: false }],
218
+ ["google.protobuf.NullValue", { int64: false }],
219
+ ]);
220
+
221
+ /** FieldOptions.JSType values. */
222
+ const JSType = Object.freeze({ normal: 0, string: 1, number: 2 });
223
+
198
224
  const RESERVED_WORDS = new Set([
199
225
  "break",
200
226
  "case",
@@ -245,7 +271,6 @@ export function generate(request) {
245
271
  const filesToGenerate = new Set(request.fileToGenerate ?? []);
246
272
  const protoFiles = request.protoFile ?? [];
247
273
  const rootJson = buildProtobufJson(protoFiles);
248
- const typeNames = declarationTypeNames(protoFiles);
249
274
  const redacted = redactedTypes(protoFiles);
250
275
  const files = [];
251
276
 
@@ -255,13 +280,14 @@ export function generate(request) {
255
280
  }
256
281
 
257
282
  const fileName = outputFileName(file.name, options.fileSuffix);
283
+ const model = declarationModel(protoFiles, file, options);
258
284
  files.push({
259
285
  name: fileName,
260
- content: generateFile(file, rootJson, redacted, options),
286
+ content: generateFile(file, rootJson, model, redacted, options),
261
287
  });
262
288
  files.push({
263
289
  name: declarationFileName(fileName),
264
- content: generateDeclarationFile(protoFiles, file, typeNames, redacted, options),
290
+ content: generateDeclarationFile(file, model, redacted, options),
265
291
  });
266
292
  }
267
293
 
@@ -277,6 +303,7 @@ export function parseOptions(parameter) {
277
303
  runtimeImport: "@trevrpc/trevrpc-js",
278
304
  runtimeTypeImport: null,
279
305
  fileSuffix: ".trevrpc.js",
306
+ int64: "bigint",
280
307
  };
281
308
 
282
309
  for (const option of parameter.split(",")) {
@@ -301,6 +328,14 @@ export function parseOptions(parameter) {
301
328
  case "file_suffix":
302
329
  options.fileSuffix = value;
303
330
  break;
331
+ case "int64":
332
+ if (!Int64Representations.includes(value)) {
333
+ throw new Error(
334
+ `invalid trevrpc-js int64 ${JSON.stringify(value)}; expected one of ${Int64Representations.join(", ")}`,
335
+ );
336
+ }
337
+ options.int64 = value;
338
+ break;
304
339
  default:
305
340
  throw new Error(`unknown trevrpc-js option ${JSON.stringify(key)}`);
306
341
  }
@@ -334,40 +369,63 @@ export function buildProtobufJson(files) {
334
369
  return root;
335
370
  }
336
371
 
337
- function generateFile(file, rootJson, redacted, options) {
372
+ function generateFile(file, rootJson, model, redacted, options) {
338
373
  const services = (file.service ?? []).map((service) =>
339
374
  serviceDescriptor(file, service, redacted),
340
375
  );
341
376
  const serviceExports = services.map((service) => generateService(service)).join("\n");
377
+ const enumExports = model.enums
378
+ .filter(({ shared }) => !shared)
379
+ .map(generateEnum)
380
+ .join("\n\n");
342
381
 
343
382
  return `// Code generated by protoc-gen-trevrpc-js. DO NOT EDIT.\n\nimport { createRoot, createServiceClient } from ${JSON.stringify(
344
383
  options.runtimeImport,
345
- )};\n\nexport const root = createRoot(${JSON.stringify(rootJson, null, 2)});\n\n${serviceExports}`;
384
+ )};\n\nexport const root = createRoot(${JSON.stringify(rootJson, null, 2)}, { int64: ${JSON.stringify(options.int64)} });\n\n${enumExports === "" ? "" : `${enumExports}\n\n`}${serviceExports}`;
346
385
  }
347
386
 
348
- function generateDeclarationFile(protoFiles, file, typeNames, redacted, options) {
349
- const messages = collectMessages(protoFiles)
350
- .filter(({ message }) => !message.options?.mapEntry)
351
- .map((message) => generateMessageDeclaration(message, typeNames))
387
+ /** The runtime value of an enum: a frozen object of its values by name. */
388
+ function generateEnum({ fullName, name, values }) {
389
+ const entries = values.map(({ key, number }) => ` ${typescriptPropertyName(key)}: ${number},`);
390
+ return `/** Values of the ${fullName} enum. */\nexport const ${name} = Object.freeze({\n${entries.join("\n")}\n});`;
391
+ }
392
+
393
+ function generateDeclarationFile(file, model, redacted, options) {
394
+ const context = { model, runtimeTypes: new Set(), usesLong: false };
395
+ const enums = model.enums
396
+ .filter(({ shared }) => !shared)
397
+ .map(generateEnumDeclaration)
398
+ .join("\n\n");
399
+ const messages = model.messages
400
+ .filter(({ shared }) => !shared)
401
+ .map((message) => generateMessageDeclarations(message, context))
352
402
  .join("\n\n");
353
403
  const services = (file.service ?? [])
354
404
  .map((service) => serviceDescriptor(file, service, redacted))
355
- .map((service) => generateServiceDeclaration(service, typeNames))
405
+ .map((service) => generateServiceDeclaration(service, context))
356
406
  .join("\n\n");
357
- const sections = [messages, services].filter(Boolean).join("\n\n");
358
- const protobufImports = sections.includes("string | Long") ? "Long, Root" : "Root";
407
+ const sections = [enums, messages, services].filter(Boolean).join("\n\n");
408
+ const protobufImports = context.usesLong ? "Long, Root" : "Root";
359
409
  const runtimeImports = [
360
410
  "CallOptions",
411
+ "ClientOptions",
361
412
  "RpcServiceDescriptor",
362
413
  "Transport",
414
+ ...context.runtimeTypes,
363
415
  ...GeneratedTypeImports.filter((name) => sections.includes(`${name}<`)),
364
- ].sort();
416
+ ].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
365
417
 
366
418
  return `// Code generated by protoc-gen-trevrpc-js. DO NOT EDIT.\n\nimport type { ${protobufImports} } from "protobufjs";\n\nimport type { ${runtimeImports.join(", ")} } from ${JSON.stringify(
367
419
  options.runtimeTypeImport,
368
420
  )};\n\nexport declare const root: Root;\n\n${sections}\n`;
369
421
  }
370
422
 
423
+ /** The declaration of an enum's runtime value and of the type of its values. */
424
+ function generateEnumDeclaration({ fullName, name, values }) {
425
+ const entries = values.map(({ key, number }) => `${typescriptPropertyName(key)}: ${number}`);
426
+ return `/** Values of the ${fullName} enum. */\nexport declare const ${name}: Readonly<{ ${entries.join("; ")} }>;\n/** A value of the ${fullName} enum. */\nexport type ${name} = (typeof ${name})[keyof typeof ${name}];`;
427
+ }
428
+
371
429
  function generateService(service) {
372
430
  const className = `${pascalIdentifier(service.name)}Client`;
373
431
  const factoryName = `create${className}`;
@@ -385,19 +443,20 @@ function generateServiceMethod(jsName, method) {
385
443
  return ` /** Calls the ${method.name} RPC. */\n ${jsName}(request, options = {}) {\n return this._client.${jsName}(request, options);\n }`;
386
444
  }
387
445
 
388
- function generateServiceDeclaration(service, typeNames) {
446
+ function generateServiceDeclaration(service, context) {
389
447
  const className = `${pascalIdentifier(service.name)}Client`;
390
448
  const factoryName = `create${className}`;
391
449
  const methods = Object.entries(service.methods)
392
- .map(([jsName, method]) => generateMethodDeclaration(jsName, method, typeNames))
450
+ .map(([jsName, method]) => generateMethodDeclaration(jsName, method, context))
393
451
  .join("\n");
394
452
 
395
- return `/** Service descriptor for the ${service.name} service. */\nexport declare const ${service.exportName}: RpcServiceDescriptor;\n\n/** Client for the ${service.name} service. */\nexport declare class ${className} {\n /** Creates a client for the ${service.name} service. */\n constructor(transport: Transport, options?: CallOptions);\n\n${methods}\n}\n\n/** Creates a client for the ${service.name} service. */\nexport declare function ${factoryName}(transport: Transport, options?: CallOptions): ${className};`;
453
+ return `/** Service descriptor for the ${service.name} service. */\nexport declare const ${service.exportName}: RpcServiceDescriptor;\n\n/** Client for the ${service.name} service. */\nexport declare class ${className} {\n /** Creates a client for the ${service.name} service. */\n constructor(transport: Transport, options?: ClientOptions);\n\n${methods}\n}\n\n/** Creates a client for the ${service.name} service. */\nexport declare function ${factoryName}(transport: Transport, options?: ClientOptions): ${className};`;
396
454
  }
397
455
 
398
- function generateMethodDeclaration(jsName, method, typeNames) {
399
- const inputType = declarationTypeReference(method.inputType, typeNames);
400
- const outputType = declarationTypeReference(method.outputType, typeNames);
456
+ function generateMethodDeclaration(jsName, method, context) {
457
+ // Requests take the input shape; responses are decoded messages.
458
+ const inputType = messageTypeReference(method.inputType, "input", context);
459
+ const outputType = messageTypeReference(method.outputType, "output", context);
401
460
 
402
461
  switch (method.kind) {
403
462
  case "unary":
@@ -551,19 +610,122 @@ function redactedTypes(files) {
551
610
  return redacted;
552
611
  }
553
612
 
554
- function declarationTypeNames(files) {
555
- const names = new Map();
613
+ /**
614
+ * The declarations of a generated file: its own messages and enums and every type they and its
615
+ * services use, directly or through other messages. `messages` holds each message's `name` (the
616
+ * decoded shape) and `initName` (the input shape), `enums` each enum's `name` and `values`, and
617
+ * `names` maps full names to them. Well-known types the runtime declares are `shared`: their names
618
+ * are reserved first and refer to the runtime's declarations.
619
+ */
620
+ function declarationModel(files, file, options) {
556
621
  const usedNames = new Set();
622
+ const names = new Map();
623
+ const isShared = (fullName) => {
624
+ const shared = SharedWellKnownTypes.get(fullName);
625
+ return shared !== undefined && (!shared.int64 || options.int64 === "bigint");
626
+ };
627
+ const used = usedTypes(files, file);
628
+ const messages = collectMessages(files)
629
+ .filter(({ fullName, message }) => used.has(fullName) && !message.options?.mapEntry)
630
+ .map((entry) => ({ ...entry, shared: isShared(entry.fullName) }));
631
+ const enums = collectEnums(files)
632
+ .filter(({ fullName }) => used.has(fullName))
633
+ .map((entry) => ({ ...entry, shared: isShared(entry.fullName) }));
634
+ const declared = [...messages, ...enums];
635
+
636
+ for (const entry of declared.filter(({ shared }) => shared)) {
637
+ entry.name = uniqueName(lastTypeSegment(entry.fullName), usedNames);
638
+ }
639
+ for (const entry of declared.filter(({ shared }) => !shared)) {
640
+ entry.name = uniqueName(pascalIdentifier(lastTypeSegment(entry.fullName)), usedNames);
641
+ }
642
+ for (const message of messages) {
643
+ message.initName = message.shared
644
+ ? `${message.name}Init`
645
+ : uniqueName(`${message.name}Init`, usedNames);
646
+ }
647
+ for (const entry of declared) {
648
+ names.set(entry.fullName, entry);
649
+ }
650
+ for (const enumEntry of enums) {
651
+ enumEntry.values = enumValues(enumEntry);
652
+ }
653
+ return { messages, enums, names, int64: options.int64 };
654
+ }
557
655
 
558
- for (const { fullName, message } of collectMessages(files)) {
559
- if (message.options?.mapEntry) {
656
+ /**
657
+ * The full names of the types a generated file declares: the messages and enums `file` defines,
658
+ * the request and response types of its services, and every type their fields use, transitively.
659
+ */
660
+ function usedTypes(files, file) {
661
+ const messages = new Map(
662
+ collectMessages(files).map(({ fullName, message }) => [fullName, message]),
663
+ );
664
+ const used = new Set();
665
+ const pending = [
666
+ ...collectMessages([file]).map(({ fullName }) => fullName),
667
+ ...collectEnums([file]).map(({ fullName }) => fullName),
668
+ ...(file.service ?? []).flatMap((service) =>
669
+ (service.method ?? []).flatMap((method) =>
670
+ [method.inputType, method.outputType].map(stripLeadingDot),
671
+ ),
672
+ ),
673
+ ];
674
+ while (pending.length > 0) {
675
+ const fullName = pending.pop();
676
+ if (used.has(fullName)) {
560
677
  continue;
561
678
  }
562
-
563
- names.set(fullName, uniqueName(pascalIdentifier(lastTypeSegment(fullName)), usedNames));
679
+ used.add(fullName);
680
+ for (const field of messages.get(fullName)?.field ?? []) {
681
+ if (field.typeName != null) {
682
+ pending.push(stripLeadingDot(field.typeName));
683
+ }
684
+ }
564
685
  }
686
+ return used;
687
+ }
688
+
689
+ /**
690
+ * The values of an enum, each with the `key` it has in the exported object: its name without the
691
+ * enum's prefix (`TIME_INTERVAL_HOUR` of `TimeInterval` is `HOUR`) when every value has that prefix
692
+ * and every key that leaves is a distinct identifier, as protobuf-es does; its full name otherwise.
693
+ */
694
+ function enumValues({ fullName, enumType }) {
695
+ const values = (enumType.value ?? []).map((value) => ({
696
+ name: value.name ?? "",
697
+ number: value.number ?? 0,
698
+ }));
699
+ const prefix = `${upperSnakeCase(lastTypeSegment(fullName))}_`;
700
+ const stripped = values.map(({ name }) => name.slice(prefix.length));
701
+ const strip =
702
+ values.length > 0 &&
703
+ values.every(({ name }) => name.startsWith(prefix)) &&
704
+ stripped.every((key) => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) &&
705
+ new Set(stripped).size === stripped.length;
706
+ return values.map((value, index) => ({ ...value, key: strip ? stripped[index] : value.name }));
707
+ }
708
+
709
+ /** Converts a PascalCase or camelCase name to UPPER_SNAKE_CASE. */
710
+ function upperSnakeCase(name) {
711
+ return name.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase();
712
+ }
565
713
 
566
- return names;
714
+ /** Every enum of `files`, top-level and nested in messages, with its full name. */
715
+ function collectEnums(files) {
716
+ const enums = [];
717
+ for (const file of files) {
718
+ const scope = file.package ?? "";
719
+ for (const enumType of file.enumType ?? []) {
720
+ enums.push({ fullName: joinTypeName(scope, enumType.name), enumType });
721
+ }
722
+ }
723
+ for (const { fullName, message } of collectMessages(files)) {
724
+ for (const enumType of message.enumType ?? []) {
725
+ enums.push({ fullName: joinTypeName(fullName, enumType.name), enumType });
726
+ }
727
+ }
728
+ return enums;
567
729
  }
568
730
 
569
731
  function collectMessages(files) {
@@ -592,29 +754,35 @@ function collectMessage(messages, fullName, message) {
592
754
  }
593
755
  }
594
756
 
595
- function generateMessageDeclaration({ fullName, message }, typeNames) {
596
- const name = typeNames.get(fullName);
757
+ /**
758
+ * The two declarations of a message: the decoded shape that calls return and streams yield, in
759
+ * which every field reads a value (scalars and enums their default, repeated fields and maps an
760
+ * empty array and object, message and `optional` fields null when unset), and the input shape that
761
+ * requests take, in which every field may be left out.
762
+ */
763
+ function generateMessageDeclarations({ fullName, message, name, initName }, context) {
597
764
  const mapEntries = new Map(
598
765
  (message.nestedType ?? [])
599
766
  .filter((nested) => nested.options?.mapEntry)
600
767
  .map((nested) => [nested.name, nested]),
601
768
  );
602
- const fields = (message.field ?? [])
603
- .map((field) => generateFieldDeclaration(field, mapEntries, typeNames, fullName))
604
- .join("\n");
769
+ const fields = (shape) =>
770
+ (message.field ?? [])
771
+ .map((field) => generateFieldDeclaration(field, mapEntries, shape, context))
772
+ .join("\n");
773
+ const body = (shape) => {
774
+ const declared = fields(shape);
775
+ return declared === "" ? "{}" : `{\n${declared}\n}`;
776
+ };
605
777
 
606
- return `export interface ${name} {\n${fields}\n}`;
778
+ return `/** A decoded ${fullName} message. */\nexport interface ${name} ${body("output")}\n\n/** A ${fullName} message to send; every field may be left out. */\nexport interface ${initName} ${body("input")}`;
607
779
  }
608
780
 
609
- function generateFieldDeclaration(field, mapEntries, typeNames, currentFullName) {
610
- const optional = field.label === FieldLabel.Required ? "" : "?";
781
+ function generateFieldDeclaration(field, mapEntries, shape, context) {
782
+ const optional = shape === "input" && field.label !== FieldLabel.Required ? "?" : "";
611
783
  const nullable = decodesAbsentAsNull(field) ? " | null" : "";
612
- return ` ${typescriptPropertyName(fieldJsonName(field))}${optional}: ${fieldTypeScriptType(
613
- field,
614
- mapEntries,
615
- typeNames,
616
- currentFullName,
617
- )}${nullable};`;
784
+ const type = fieldTypeScriptType(field, mapEntries, shape, context);
785
+ return ` ${typescriptPropertyName(fieldJsonName(field))}${optional}: ${type}${nullable};`;
618
786
  }
619
787
 
620
788
  /** Reports whether protobuf.js decodes the field as null when it is absent. */
@@ -629,19 +797,23 @@ function decodesAbsentAsNull(field) {
629
797
  );
630
798
  }
631
799
 
632
- function fieldTypeScriptType(field, mapEntries, typeNames, currentFullName) {
800
+ function fieldTypeScriptType(field, mapEntries, shape, context) {
633
801
  const mapEntry = mapEntryForField(field, mapEntries);
634
802
  if (mapEntry != null) {
635
803
  const [, valueField] = mapEntry.field ?? [];
636
- const valueType = scalarTypeScriptType(valueField, typeNames, currentFullName);
637
- return `Record<string, ${valueType}>`;
804
+ return `Record<string, ${scalarTypeScriptType(valueField, shape, context)}>`;
638
805
  }
639
806
 
640
- const scalarType = scalarTypeScriptType(field, typeNames, currentFullName);
641
- return field.label === FieldLabel.Repeated ? `${scalarType}[]` : scalarType;
807
+ const scalarType = scalarTypeScriptType(field, shape, context);
808
+ return field.label === FieldLabel.Repeated ? `${arrayElementType(scalarType)}[]` : scalarType;
642
809
  }
643
810
 
644
- function scalarTypeScriptType(field, typeNames, currentFullName) {
811
+ /** Parenthesizes a union so that it can be an array's element type. */
812
+ function arrayElementType(type) {
813
+ return type.includes(" | ") ? `(${type})` : type;
814
+ }
815
+
816
+ function scalarTypeScriptType(field, shape, context) {
645
817
  switch (field?.type) {
646
818
  case FieldType.Double:
647
819
  case FieldType.Float:
@@ -650,14 +822,15 @@ function scalarTypeScriptType(field, typeNames, currentFullName) {
650
822
  case FieldType.Sint32:
651
823
  case FieldType.Fixed32:
652
824
  case FieldType.Sfixed32:
653
- case FieldType.Enum:
654
825
  return "number";
826
+ case FieldType.Enum:
827
+ return declaredName(field.typeName, "output", context) ?? "number";
655
828
  case FieldType.Int64:
656
829
  case FieldType.Uint64:
657
830
  case FieldType.Sint64:
658
831
  case FieldType.Fixed64:
659
832
  case FieldType.Sfixed64:
660
- return "number | string | Long";
833
+ return int64TypeScriptType(field, shape, context);
661
834
  case FieldType.Bool:
662
835
  return "boolean";
663
836
  case FieldType.String:
@@ -666,36 +839,63 @@ function scalarTypeScriptType(field, typeNames, currentFullName) {
666
839
  return "Uint8Array";
667
840
  case FieldType.Group:
668
841
  case FieldType.Message:
669
- return declarationTypeReference(
670
- resolveTypeName(field.typeName, typeNames, currentFullName),
671
- typeNames,
672
- );
842
+ return messageTypeReference(field.typeName, shape, context);
673
843
  default:
674
844
  throw new Error(`unsupported protobuf field type ${field?.type}`);
675
845
  }
676
846
  }
677
847
 
678
- function declarationTypeReference(typeName, typeNames) {
679
- return typeNames.get(stripLeadingDot(typeName)) ?? "Record<string, unknown>";
848
+ /**
849
+ * The type of a 64-bit field: the representation the decoded shape has (the field's `jstype`, or
850
+ * the generator's `int64` option), and every representation the encoder accepts in the input.
851
+ */
852
+ function int64TypeScriptType(field, shape, context) {
853
+ const representation = int64Representation(field, context.model.int64);
854
+ if (representation === "long") {
855
+ context.usesLong = true;
856
+ }
857
+ if (shape === "input") {
858
+ return representation === "long"
859
+ ? "bigint | number | string | Long"
860
+ : "bigint | number | string";
861
+ }
862
+ return representation === "long" ? "Long" : representation;
680
863
  }
681
864
 
682
- function resolveTypeName(typeName, typeNames, currentFullName) {
683
- const stripped = stripLeadingDot(typeName);
684
- if (typeNames.has(stripped)) {
685
- return stripped;
865
+ /** How decoded messages represent a 64-bit field: by its `jstype`, else by the `int64` option. */
866
+ function int64Representation(field, representation) {
867
+ switch (field.options?.jstype) {
868
+ case JSType.string:
869
+ return "string";
870
+ case JSType.number:
871
+ return "number";
872
+ default:
873
+ return representation;
686
874
  }
875
+ }
687
876
 
688
- const scope = currentFullName.split(".");
689
- scope.pop();
690
- while (scope.length > 0) {
691
- const candidate = `${scope.join(".")}.${stripped}`;
692
- if (typeNames.has(candidate)) {
693
- return candidate;
694
- }
695
- scope.pop();
696
- }
877
+ /**
878
+ * The declared name of a message type in `shape`, `"output"` or `"input"`, noting the runtime
879
+ * declarations it uses.
880
+ */
881
+ function messageTypeReference(typeName, shape, context) {
882
+ return declaredName(typeName, shape, context) ?? "Record<string, unknown>";
883
+ }
697
884
 
698
- return stripped;
885
+ /**
886
+ * The declared name of a message or enum type in `shape` (enums have one for both), or undefined
887
+ * for a type without a declaration. A name the runtime declares is noted for import.
888
+ */
889
+ function declaredName(typeName, shape, context) {
890
+ const entry = context.model.names.get(stripLeadingDot(typeName));
891
+ if (entry === undefined) {
892
+ return undefined;
893
+ }
894
+ const name = shape === "input" && entry.initName !== undefined ? entry.initName : entry.name;
895
+ if (entry.shared) {
896
+ context.runtimeTypes.add(name);
897
+ }
898
+ return name;
699
899
  }
700
900
 
701
901
  function joinTypeName(prefix, name) {
@@ -807,6 +1007,13 @@ function fieldToJson(field, mapEntries) {
807
1007
  // only when this option marks its synthetic oneof.
808
1008
  json.options = { ...json.options, proto3_optional: true };
809
1009
  }
1010
+ // The runtime reads it to decode the field as a string or number instead of the root's int64
1011
+ // representation.
1012
+ if (field.options?.jstype === JSType.string) {
1013
+ json.options = { ...json.options, jstype: "JS_STRING" };
1014
+ } else if (field.options?.jstype === JSType.number) {
1015
+ json.options = { ...json.options, jstype: "JS_NUMBER" };
1016
+ }
810
1017
 
811
1018
  return json;
812
1019
  }