@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.
- package/README.md +34 -5
- package/package.json +12 -4
- package/src/call.js +401 -58
- package/src/channel.js +47 -10
- package/src/client.js +93 -22
- package/src/compression.js +330 -0
- package/src/connection-metadata.js +114 -0
- package/src/datagram.js +48 -27
- package/src/framing.js +23 -6
- package/src/generator.js +134 -14
- package/src/index.d.ts +75 -14
- package/src/qmux/session.js +2 -1
- package/src/stream-compression.js +298 -0
- package/src/wire.js +43 -11
- package/src/zstd.d.ts +35 -0
- package/src/zstd.js +407 -0
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
41
|
-
datagram[offset] =
|
|
42
|
-
offset = writeVarint(datagram, offset + 1,
|
|
43
|
-
datagram.set(
|
|
44
|
-
offset +=
|
|
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
|
|
52
|
-
* frames do not exactly fill it
|
|
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 ===
|
|
73
|
-
|
|
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
|
|
120
|
-
*
|
|
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
|
-
|
|
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
|
|
59
|
-
*
|
|
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.
|
|
79
|
+
checkFrameLength(message.size, maxFrameSize);
|
|
68
80
|
const requestSize = frameSize(FrameType.Request, payload.byteLength);
|
|
69
|
-
const frame = new Uint8Array(requestSize + frameSize(
|
|
70
|
-
writeFrame(frame, requestSize,
|
|
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) =>
|
|
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
|
|
416
|
-
outputType
|
|
417
|
-
...unreliableDirections(
|
|
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
|
-
|
|
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 };
|