@trevrpc/trevrpc-js 1.1.0 → 1.3.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.
@@ -0,0 +1,114 @@
1
+ // Connection metadata is request metadata a client sets once for all its calls, such as a bearer
2
+ // token, so that it crosses each connection once instead of in every REQUEST. A call offers the
3
+ // set (RpcRequest.connection_metadata with entries) until an ANSWER confirms that the server
4
+ // stored it for the session, then refers to it by ID. Calls on different streams reach the server
5
+ // in any order, so a reference only follows a confirmation. The server merges the set into each
6
+ // call's metadata, the call's own entries winning, so the client just sends both; see the wiki's
7
+ // Connection Metadata section. This module holds the channel's set and what each session
8
+ // confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER.
9
+ import { normalizeMetadata } from "./metadata.js";
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. */
15
+ export function transportConnectionMetadata(transport) {
16
+ return transport[ConnectionMetadataKey];
17
+ }
18
+
19
+ /** A request that carries no connection metadata. */
20
+ const NoConnectionMetadata = Object.freeze({ field: undefined, offered: 0 });
21
+
22
+ /**
23
+ * A channel's connection metadata, shared by its sessions: the current set, whose ID changes with
24
+ * it, and the state of each session.
25
+ */
26
+ export class ConnectionMetadataSource {
27
+ /** The current set, `{ id, metadata }`, or undefined without connection metadata. */
28
+ #current;
29
+ #ids = 0;
30
+ #sessions = new WeakMap();
31
+
32
+ /** Throws InvalidArgument if `metadata` is invalid, like call metadata. */
33
+ constructor(metadata) {
34
+ this.set(metadata);
35
+ }
36
+
37
+ /** The current set, or undefined. */
38
+ get current() {
39
+ return this.#current;
40
+ }
41
+
42
+ /**
43
+ * Replaces the connection metadata, with keys normalized like a call's; empty or missing
44
+ * metadata removes it. The new set has a new ID, so each session offers it again.
45
+ */
46
+ set(metadata) {
47
+ const normalized = metadata == null ? {} : normalizeMetadata(metadata);
48
+ const keys = Object.keys(normalized);
49
+ if (keys.length === 0) {
50
+ this.#current = undefined;
51
+ return;
52
+ }
53
+ // Copied, so a caller changing its bytes later does not change what is sent.
54
+ for (const key of keys) {
55
+ normalized[key] = normalized[key].slice();
56
+ }
57
+ // IDs are uint32 on the wire and 0 means none: they run from 1 to 2^32 - 1 and wrap.
58
+ this.#ids = (this.#ids % 0xffffffff) + 1;
59
+ this.#current = { id: this.#ids, metadata: normalized };
60
+ }
61
+
62
+ /**
63
+ * The state of one session, keyed like the compression state: what the server confirmed on the
64
+ * connection that `key` identifies, dropped with it.
65
+ */
66
+ session(key) {
67
+ let state = this.#sessions.get(key);
68
+ if (state === undefined) {
69
+ state = new SessionConnectionMetadata(this);
70
+ this.#sessions.set(key, state);
71
+ }
72
+ return state;
73
+ }
74
+ }
75
+
76
+ class SessionConnectionMetadata {
77
+ #source;
78
+ /** The ID of the set the server confirmed on this session, or zero. */
79
+ #confirmed = 0;
80
+
81
+ constructor(source) {
82
+ this.#source = source;
83
+ }
84
+
85
+ /**
86
+ * The connection metadata of one REQUEST as `{ field, offered }`: `field` is the value of
87
+ * `connectionMetadata`, a reference to the current set once the session confirmed it and an
88
+ * offer of it otherwise, or undefined without a set; `offered` is the ID the REQUEST offers,
89
+ * which `learn` checks the ANSWER against, or zero.
90
+ */
91
+ prepare() {
92
+ const set = this.#source.current;
93
+ if (set === undefined) {
94
+ return NoConnectionMetadata;
95
+ }
96
+ if (this.#confirmed === set.id) {
97
+ return { field: { id: set.id }, offered: 0 };
98
+ }
99
+ return { field: { id: set.id, metadata: set.metadata }, offered: set.id };
100
+ }
101
+
102
+ /** Records that the server stored the `offered` set, once the call's ANSWER confirms it. */
103
+ learn(offered, answer) {
104
+ // Only the current set counts: a late confirmation of one since replaced would make calls
105
+ // offer the current one again.
106
+ if (
107
+ offered !== 0 &&
108
+ answer.connectionMetadata === offered &&
109
+ this.#source.current?.id === offered
110
+ ) {
111
+ this.#confirmed = offered;
112
+ }
113
+ }
114
+ }
package/src/datagram.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // DatagramFlow on the endpoint of the session that carries them. QMux over WebSocket has no
5
5
  // datagrams, so those calls never get a flow and everything stays on the stream.
6
6
  import { byteView } from "./bytes.js";
7
+ import { FrameType } from "./framing.js";
7
8
  import { readVarint, varintLength, writeVarint } from "./varint.js";
8
9
 
9
10
  /**
@@ -19,16 +20,17 @@ export const FallbackMaxDatagramSize = 1100;
19
20
  export const FlowQueueMessages = 32;
20
21
  export const FlowQueueBytes = 1 << 20;
21
22
 
22
- const MessageFrameType = 0;
23
-
24
- /** Encoded size of one message as a MESSAGE frame inside a datagram. */
25
- export function messageFrameSize(message) {
26
- return 1 + varintLength(message.byteLength) + message.byteLength;
23
+ /**
24
+ * Encoded size of one outgoing message (`{ type, payload }` from encodeMessage) as a MESSAGE or
25
+ * ZSTD_MESSAGE frame inside a datagram.
26
+ */
27
+ export function messageFrameSize({ payload }) {
28
+ return 1 + varintLength(payload.byteLength) + payload.byteLength;
27
29
  }
28
30
 
29
31
  /**
30
- * Encodes a datagram payload: the flow ID as a QUIC varint, then one MESSAGE frame per message,
31
- * in order.
32
+ * Encodes a datagram payload: the flow ID as a QUIC varint, then the frame of each outgoing
33
+ * message, in order.
32
34
  */
33
35
  export function encodeDatagram(flow, messages) {
34
36
  let size = varintLength(flow);
@@ -37,19 +39,20 @@ export function encodeDatagram(flow, messages) {
37
39
  }
38
40
  const datagram = new Uint8Array(size);
39
41
  let offset = writeVarint(datagram, 0, flow);
40
- for (const message of messages) {
41
- datagram[offset] = MessageFrameType;
42
- offset = writeVarint(datagram, offset + 1, message.byteLength);
43
- datagram.set(message, offset);
44
- offset += message.byteLength;
42
+ for (const { type, payload } of messages) {
43
+ datagram[offset] = type;
44
+ offset = writeVarint(datagram, offset + 1, payload.byteLength);
45
+ datagram.set(payload, offset);
46
+ offset += payload.byteLength;
45
47
  }
46
48
  return datagram;
47
49
  }
48
50
 
49
51
  /**
50
52
  * Decodes a datagram payload as `{ flow, messages }`, or null if it has no complete flow ID. The
51
- * messages are the payloads of its MESSAGE frames; every other frame is skipped. A datagram whose
52
- * frames do not exactly fill it decodes to no messages at all.
53
+ * messages are its MESSAGE and ZSTD_MESSAGE frames as `{ type, payload }`; every other frame is
54
+ * skipped. A datagram whose frames do not exactly fill it, or that holds a ZSTD_STREAM_MESSAGE,
55
+ * which needs a stream's ordered delivery, decodes to no messages at all.
53
56
  */
54
57
  export function decodeDatagram(datagram) {
55
58
  const bytes = byteView(datagram);
@@ -69,8 +72,11 @@ export function decodeDatagram(datagram) {
69
72
  if (end > bytes.byteLength) {
70
73
  return { flow: flow.value, messages: [] };
71
74
  }
72
- if (type.value === MessageFrameType) {
73
- messages.push(bytes.subarray(length.next, end));
75
+ if (type.value === FrameType.ZstdStreamMessage) {
76
+ return { flow: flow.value, messages: [] };
77
+ }
78
+ if (type.value === FrameType.Message || type.value === FrameType.ZstdMessage) {
79
+ messages.push({ type: type.value, payload: bytes.subarray(length.next, end) });
74
80
  }
75
81
  offset = end;
76
82
  }
@@ -116,14 +122,15 @@ class DatagramEndpoint {
116
122
 
117
123
  /**
118
124
  * Allocates the next flow of this session. A flow that `receive`s is registered with the
119
- * reader, so datagrams for its ID reach it. Returns undefined if the session cannot read
120
- * datagrams.
125
+ * reader, so datagrams for its ID reach it; `inflate` maps each received `{ type, payload }`
126
+ * message to its payload, decompressed, and throws if it is invalid. Returns undefined if the
127
+ * session cannot read datagrams.
121
128
  */
122
- openFlow({ receive }) {
129
+ openFlow({ receive, inflate }) {
123
130
  if (!this.#start()) {
124
131
  return undefined;
125
132
  }
126
- const flow = new DatagramFlow(this, this.#nextFlow);
133
+ const flow = new DatagramFlow(this, this.#nextFlow, inflate);
127
134
  this.#nextFlow += 1;
128
135
  if (receive) {
129
136
  this.#flows.set(flow.id, flow);
@@ -207,11 +214,7 @@ class DatagramEndpoint {
207
214
  const datagram = decodeDatagram(value);
208
215
  const flow = datagram === null ? undefined : this.#flows.get(datagram.flow);
209
216
  // Unknown and ended flows drop the datagram, and so do malformed ones (no messages).
210
- if (flow !== undefined) {
211
- for (const message of datagram.messages) {
212
- flow.deliver(message);
213
- }
214
- }
217
+ flow?.deliverAll(datagram.messages);
215
218
  }
216
219
  } catch {
217
220
  // A failed session ends its calls through their streams.
@@ -253,21 +256,39 @@ class DatagramEndpoint {
253
256
  */
254
257
  export class DatagramFlow {
255
258
  #endpoint;
259
+ #inflate;
256
260
  #handler;
257
261
  #buffered = [];
258
262
  #bufferedBytes = 0;
259
263
  #released = false;
260
264
 
261
- constructor(endpoint, id) {
265
+ constructor(endpoint, id, inflate) {
262
266
  this.#endpoint = endpoint;
267
+ this.#inflate = inflate;
263
268
  this.id = id;
264
269
  }
265
270
 
271
+ /**
272
+ * Delivers the messages of one received datagram, decompressed. A datagram with an invalid
273
+ * message is dropped whole.
274
+ */
275
+ deliverAll(messages) {
276
+ let payloads;
277
+ try {
278
+ payloads = messages.map(this.#inflate);
279
+ } catch {
280
+ return;
281
+ }
282
+ for (const payload of payloads) {
283
+ this.#deliver(payload);
284
+ }
285
+ }
286
+
266
287
  /**
267
288
  * Delivers one received message. It must not block or throw. Without a handler it is buffered,
268
289
  * and dropped when the buffer is full.
269
290
  */
270
- deliver(message) {
291
+ #deliver(message) {
271
292
  if (this.#handler !== undefined) {
272
293
  this.#handler(message);
273
294
  } else if (
package/src/framing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { byteView } from "./bytes.js";
2
2
  import { internal, invalidArgument, resourceExhausted, unavailable } from "./status.js";
3
3
  import { readVarint, varintLength, writeVarint } from "./varint.js";
4
- import { RpcHeaders, RpcRequest, RpcStatus, preflightWireMessage } from "./wire.js";
4
+ import { RpcAnswer, RpcHeaders, RpcRequest, RpcStatus, preflightWireMessage } from "./wire.js";
5
5
 
6
6
  export const DefaultMaxFrameSize = 4 * 1024 * 1024;
7
7
 
@@ -11,6 +11,9 @@ export const FrameType = Object.freeze({
11
11
  Request: 1,
12
12
  Headers: 2,
13
13
  Status: 3,
14
+ ZstdMessage: 4,
15
+ ZstdStreamMessage: 5,
16
+ Answer: 6,
14
17
  });
15
18
 
16
19
  const KnownFrameTypes = new Set(Object.values(FrameType));
@@ -55,8 +58,17 @@ export function encodeMessageFrame(body, maxFrameSize = DefaultMaxFrameSize) {
55
58
  }
56
59
 
57
60
  /**
58
- * Encodes a REQUEST frame, followed by a MESSAGE frame when message is given, in one buffer so
59
- * both go out in a single write.
61
+ * Encodes an outgoing message, `{ type, payload, size }` from encodeMessage, as its MESSAGE or
62
+ * ZSTD_MESSAGE frame. The message size counts against the limit, not just the payload's.
63
+ */
64
+ export function encodeOutgoingFrame(message, maxFrameSize = DefaultMaxFrameSize) {
65
+ checkFrameLength(message.size, maxFrameSize);
66
+ return encodeFrame(message.type, message.payload, maxFrameSize);
67
+ }
68
+
69
+ /**
70
+ * Encodes a REQUEST frame, followed by the frame of message, an outgoing message from
71
+ * encodeMessage, when it is given, in one buffer so both go out in a single write.
60
72
  */
61
73
  export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFrameSize) {
62
74
  const payload = marshalMessage(RpcRequest, request);
@@ -64,10 +76,10 @@ export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFr
64
76
  if (message === undefined) {
65
77
  return encodeFrame(FrameType.Request, payload, maxFrameSize);
66
78
  }
67
- checkFrameLength(message.byteLength, maxFrameSize);
79
+ checkFrameLength(message.size, maxFrameSize);
68
80
  const requestSize = frameSize(FrameType.Request, payload.byteLength);
69
- const frame = new Uint8Array(requestSize + frameSize(FrameType.Message, message.byteLength));
70
- writeFrame(frame, requestSize, FrameType.Message, message);
81
+ const frame = new Uint8Array(requestSize + frameSize(message.type, message.payload.byteLength));
82
+ writeFrame(frame, requestSize, message.type, message.payload);
71
83
  writeFrame(frame, 0, FrameType.Request, payload);
72
84
  return frame;
73
85
  }
@@ -77,6 +89,11 @@ export function encodeHeadersFrame(headers, maxFrameSize = DefaultMaxFrameSize)
77
89
  return encodeFrame(FrameType.Headers, marshalMessage(RpcHeaders, headers), maxFrameSize);
78
90
  }
79
91
 
92
+ /** Encodes an ANSWER frame from RpcAnswer fields. */
93
+ export function encodeAnswerFrame(answer, maxFrameSize = DefaultMaxFrameSize) {
94
+ return encodeFrame(FrameType.Answer, marshalMessage(RpcAnswer, answer), maxFrameSize);
95
+ }
96
+
80
97
  /** Encodes a STATUS frame from RpcStatus fields. */
81
98
  export function encodeStatusFrame(status, maxFrameSize = DefaultMaxFrameSize) {
82
99
  return encodeFrame(FrameType.Status, marshalMessage(RpcStatus, status), maxFrameSize);
package/src/generator.js CHANGED
@@ -62,6 +62,7 @@ message FieldDescriptorProto {
62
62
  optional int32 oneof_index = 9;
63
63
  optional string json_name = 10;
64
64
  optional bool proto3_optional = 17;
65
+ optional FieldOptions options = 8;
65
66
  }
66
67
 
67
68
  message OneofDescriptorProto {
@@ -101,6 +102,12 @@ message MethodOptions {
101
102
  message TrevrpcMethodOptions {
102
103
  optional bool unreliable_requests = 1;
103
104
  optional bool unreliable_responses = 2;
105
+ // trevrpc.Compression, read as its number so that unknown values reach the check.
106
+ optional int32 compression = 5;
107
+ }
108
+
109
+ message FieldOptions {
110
+ optional bool debug_redact = 16;
104
111
  }
105
112
 
106
113
  message FileOptions {
@@ -178,6 +185,9 @@ const FieldLabel = Object.freeze({
178
185
  Repeated: 3,
179
186
  });
180
187
 
188
+ /** The CodeGeneratorResponse features this generator supports. */
189
+ const FEATURE_PROTO3_OPTIONAL = 1;
190
+
181
191
  /** Generic runtime types that generated declarations import only when they use them. */
182
192
  const GeneratedTypeImports = [
183
193
  "BidirectionalStreamingCall",
@@ -236,6 +246,7 @@ export function generate(request) {
236
246
  const protoFiles = request.protoFile ?? [];
237
247
  const rootJson = buildProtobufJson(protoFiles);
238
248
  const typeNames = declarationTypeNames(protoFiles);
249
+ const redacted = redactedTypes(protoFiles);
239
250
  const files = [];
240
251
 
241
252
  for (const file of protoFiles) {
@@ -246,17 +257,17 @@ export function generate(request) {
246
257
  const fileName = outputFileName(file.name, options.fileSuffix);
247
258
  files.push({
248
259
  name: fileName,
249
- content: generateFile(file, rootJson, options),
260
+ content: generateFile(file, rootJson, redacted, options),
250
261
  });
251
262
  files.push({
252
263
  name: declarationFileName(fileName),
253
- content: generateDeclarationFile(protoFiles, file, typeNames, options),
264
+ content: generateDeclarationFile(protoFiles, file, typeNames, redacted, options),
254
265
  });
255
266
  }
256
267
 
257
- return { file: files };
268
+ return { file: files, supportedFeatures: FEATURE_PROTO3_OPTIONAL };
258
269
  } catch (error) {
259
- return { error: error.message };
270
+ return { error: error.message, supportedFeatures: FEATURE_PROTO3_OPTIONAL };
260
271
  }
261
272
  }
262
273
 
@@ -323,8 +334,10 @@ export function buildProtobufJson(files) {
323
334
  return root;
324
335
  }
325
336
 
326
- function generateFile(file, rootJson, options) {
327
- const services = (file.service ?? []).map((service) => serviceDescriptor(file, service));
337
+ function generateFile(file, rootJson, redacted, options) {
338
+ const services = (file.service ?? []).map((service) =>
339
+ serviceDescriptor(file, service, redacted),
340
+ );
328
341
  const serviceExports = services.map((service) => generateService(service)).join("\n");
329
342
 
330
343
  return `// Code generated by protoc-gen-trevrpc-js. DO NOT EDIT.\n\nimport { createRoot, createServiceClient } from ${JSON.stringify(
@@ -332,13 +345,13 @@ function generateFile(file, rootJson, options) {
332
345
  )};\n\nexport const root = createRoot(${JSON.stringify(rootJson, null, 2)});\n\n${serviceExports}`;
333
346
  }
334
347
 
335
- function generateDeclarationFile(protoFiles, file, typeNames, options) {
348
+ function generateDeclarationFile(protoFiles, file, typeNames, redacted, options) {
336
349
  const messages = collectMessages(protoFiles)
337
350
  .filter(({ message }) => !message.options?.mapEntry)
338
351
  .map((message) => generateMessageDeclaration(message, typeNames))
339
352
  .join("\n\n");
340
353
  const services = (file.service ?? [])
341
- .map((service) => serviceDescriptor(file, service))
354
+ .map((service) => serviceDescriptor(file, service, redacted))
342
355
  .map((service) => generateServiceDeclaration(service, typeNames))
343
356
  .join("\n\n");
344
357
  const sections = [messages, services].filter(Boolean).join("\n\n");
@@ -400,7 +413,7 @@ function generateMethodDeclaration(jsName, method, typeNames) {
400
413
  }
401
414
  }
402
415
 
403
- function serviceDescriptor(file, service) {
416
+ function serviceDescriptor(file, service, redacted) {
404
417
  const fullName =
405
418
  file.package === "" || file.package == null ? service.name : `${file.package}.${service.name}`;
406
419
  const methods = {};
@@ -409,12 +422,17 @@ function serviceDescriptor(file, service) {
409
422
  for (const method of service.method ?? []) {
410
423
  const jsName = uniqueName(lowerCamelIdentifier(method.name), usedNames);
411
424
  const kind = methodKind(method);
425
+ const inputType = stripLeadingDot(method.inputType);
426
+ const outputType = stripLeadingDot(method.outputType);
427
+ const methodName = `${fullName}.${method.name}`;
428
+ const options = method.options?.trevrpcMethod;
412
429
  methods[jsName] = {
413
430
  name: method.name,
414
431
  kind,
415
- inputType: stripLeadingDot(method.inputType),
416
- outputType: stripLeadingDot(method.outputType),
417
- ...unreliableDirections(`${fullName}.${method.name}`, kind, method.options?.trevrpcMethod),
432
+ inputType,
433
+ outputType,
434
+ ...unreliableDirections(methodName, kind, options),
435
+ ...compressionDirections(methodName, kind, options, { inputType, outputType }, redacted),
418
436
  };
419
437
  }
420
438
 
@@ -451,6 +469,88 @@ function unreliableDirections(methodName, kind, options) {
451
469
  return directions;
452
470
  }
453
471
 
472
+ /** `trevrpc.Compression` values, from proto/trevrpc/options.proto. */
473
+ const Compression = { unspecified: 0, message: 1, stream: 2 };
474
+
475
+ /**
476
+ * The directions a method's `compression` lets compress, as descriptor fields, leaving out message
477
+ * types with a field marked `debug_redact`. `COMPRESSION_MESSAGE` compresses messages without a
478
+ * dictionary in both directions. `COMPRESSION_STREAM` does too, for the messages outside the
479
+ * stream, and stream-compresses the directions that stream; on a unary method it is an error, and
480
+ * so is a value this generator does not know.
481
+ */
482
+ function compressionDirections(methodName, kind, options, { inputType, outputType }, redacted) {
483
+ const compression = options?.compression ?? Compression.unspecified;
484
+ if (compression === Compression.unspecified) {
485
+ return {};
486
+ }
487
+ if (compression !== Compression.message && compression !== Compression.stream) {
488
+ throw new Error(`${methodName}: unknown compression ${compression}`);
489
+ }
490
+ const directions = {};
491
+ if (!redacted.has(inputType)) {
492
+ directions.compressRequests = true;
493
+ }
494
+ if (!redacted.has(outputType)) {
495
+ directions.compressResponses = true;
496
+ }
497
+ if (compression === Compression.message) {
498
+ return directions;
499
+ }
500
+ if (kind === "unary") {
501
+ throw new Error(`${methodName}: COMPRESSION_STREAM requires a streaming method`);
502
+ }
503
+ if (
504
+ (kind === "clientStreaming" || kind === "bidirectionalStreaming") &&
505
+ !redacted.has(inputType)
506
+ ) {
507
+ directions.compressStreamRequests = true;
508
+ }
509
+ if (
510
+ (kind === "serverStreaming" || kind === "bidirectionalStreaming") &&
511
+ !redacted.has(outputType)
512
+ ) {
513
+ directions.compressStreamResponses = true;
514
+ }
515
+ return directions;
516
+ }
517
+
518
+ /**
519
+ * The full names of the message types that contain a field marked `debug_redact`, directly or in
520
+ * a message-typed field, however deeply nested.
521
+ */
522
+ function redactedTypes(files) {
523
+ const messages = new Map(
524
+ collectMessages(files).map(({ fullName, message }) => [fullName, message]),
525
+ );
526
+ const redacted = new Set();
527
+ const visiting = new Set();
528
+ const visit = (name) => {
529
+ if (redacted.has(name)) {
530
+ return true;
531
+ }
532
+ const message = messages.get(name);
533
+ if (message === undefined || visiting.has(name)) {
534
+ return false;
535
+ }
536
+ visiting.add(name);
537
+ const found = (message.field ?? []).some(
538
+ (field) =>
539
+ field.options?.debugRedact === true ||
540
+ (field.typeName != null && visit(stripLeadingDot(field.typeName))),
541
+ );
542
+ visiting.delete(name);
543
+ if (found) {
544
+ redacted.add(name);
545
+ }
546
+ return found;
547
+ };
548
+ for (const name of messages.keys()) {
549
+ visit(name);
550
+ }
551
+ return redacted;
552
+ }
553
+
454
554
  function declarationTypeNames(files) {
455
555
  const names = new Map();
456
556
  const usedNames = new Set();
@@ -508,12 +608,25 @@ function generateMessageDeclaration({ fullName, message }, typeNames) {
508
608
 
509
609
  function generateFieldDeclaration(field, mapEntries, typeNames, currentFullName) {
510
610
  const optional = field.label === FieldLabel.Required ? "" : "?";
611
+ const nullable = decodesAbsentAsNull(field) ? " | null" : "";
511
612
  return ` ${typescriptPropertyName(fieldJsonName(field))}${optional}: ${fieldTypeScriptType(
512
613
  field,
513
614
  mapEntries,
514
615
  typeNames,
515
616
  currentFullName,
516
- )};`;
617
+ )}${nullable};`;
618
+ }
619
+
620
+ /** Reports whether protobuf.js decodes the field as null when it is absent. */
621
+ function decodesAbsentAsNull(field) {
622
+ if (field.label === FieldLabel.Repeated) {
623
+ return false;
624
+ }
625
+ return (
626
+ field.proto3Optional === true ||
627
+ field.type === FieldType.Message ||
628
+ field.type === FieldType.Group
629
+ );
517
630
  }
518
631
 
519
632
  function fieldTypeScriptType(field, mapEntries, typeNames, currentFullName) {
@@ -689,6 +802,11 @@ function fieldToJson(field, mapEntries) {
689
802
  if (field.defaultValue != null && field.defaultValue !== "") {
690
803
  json.options = { default: field.defaultValue };
691
804
  }
805
+ if (field.proto3Optional) {
806
+ // protobuf.js gives the field presence, decoding it as null when absent,
807
+ // only when this option marks its synthetic oneof.
808
+ json.options = { ...json.options, proto3_optional: true };
809
+ }
692
810
 
693
811
  return json;
694
812
  }
@@ -709,7 +827,9 @@ function oneofsToJson(message) {
709
827
  const oneofs = {};
710
828
  for (const [index, oneof] of (message.oneofDecl ?? []).entries()) {
711
829
  const fields = (message.field ?? [])
712
- .filter((field) => field.oneofIndex === index)
830
+ // A decoded descriptor reads an unset oneof_index as 0 from its
831
+ // prototype, so only an own property places the field in a oneof.
832
+ .filter((field) => Object.hasOwn(field, "oneofIndex") && field.oneofIndex === index)
713
833
  .map(fieldJsonName);
714
834
  if (fields.length > 0) {
715
835
  oneofs[oneof.name] = { oneof: fields };