@zero-server/grpc 0.9.0 → 0.9.2

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,461 @@
1
+ /**
2
+ * @module grpc/metadata
3
+ * @description gRPC metadata container — typed key-value pairs transmitted as
4
+ * HTTP/2 headers (initial metadata) and trailers (trailing metadata).
5
+ * Keys ending in `-bin` carry binary values (base64-encoded on the wire).
6
+ * All other keys carry ASCII string values.
7
+ *
8
+ * @example
9
+ * const md = new Metadata();
10
+ * md.set('x-request-id', '123');
11
+ * md.add('x-tags', 'alpha');
12
+ * md.add('x-tags', 'beta');
13
+ * md.getAll('x-tags'); // ['alpha', 'beta']
14
+ *
15
+ * @example | Binary metadata
16
+ * md.set('icon-bin', Buffer.from([0x89, 0x50, 0x4e, 0x47]));
17
+ * md.get('icon-bin'); // <Buffer 89 50 4e 47>
18
+ */
19
+
20
+ const log = require('../debug')('zero:grpc');
21
+
22
+ /**
23
+ * Reserved HTTP/2 pseudo-headers that must not be treated as gRPC metadata.
24
+ * @type {Set<string>}
25
+ * @private
26
+ */
27
+ const RESERVED = new Set([':authority', ':method', ':path', ':scheme', ':status']);
28
+
29
+ /**
30
+ * Headers managed by the gRPC framing layer, not user metadata.
31
+ * @type {Set<string>}
32
+ * @private
33
+ */
34
+ const GRPC_INTERNAL = new Set([
35
+ 'content-type', 'te', 'grpc-timeout', 'grpc-encoding',
36
+ 'grpc-accept-encoding', 'grpc-status', 'grpc-message',
37
+ 'user-agent', 'host',
38
+ ]);
39
+
40
+ /**
41
+ * Maximum metadata key length (prevents abuse).
42
+ * @type {number}
43
+ */
44
+ const MAX_KEY_LENGTH = 256;
45
+
46
+ /**
47
+ * Maximum total metadata size in bytes (soft limit — 8 KB default, configurable).
48
+ * @type {number}
49
+ */
50
+ const DEFAULT_MAX_METADATA_SIZE = 8192;
51
+
52
+ // -- Metadata Class ----------------------------------------
53
+
54
+ /**
55
+ * gRPC metadata container — type-safe key-value pairs for headers and trailers.
56
+ *
57
+ * @class
58
+ *
59
+ * @param {object} [opts] - Configuration options.
60
+ * @param {number} [opts.maxSize=8192] - Maximum total serialized metadata size in bytes.
61
+ *
62
+ * @example
63
+ * const md = new Metadata();
64
+ * md.set('authorization', 'Bearer tok123');
65
+ * md.set('x-trace-id', crypto.randomUUID());
66
+ */
67
+ class Metadata
68
+ {
69
+ constructor(opts = {})
70
+ {
71
+ /** @private */
72
+ this._map = new Map();
73
+ /** @private */
74
+ this._maxSize = opts.maxSize || DEFAULT_MAX_METADATA_SIZE;
75
+ }
76
+
77
+ // -- Core Operations -----------------------------------
78
+
79
+ /**
80
+ * Set a metadata key to a single value, replacing any existing values.
81
+ * Binary keys (ending in `-bin`) accept Buffer values; all others accept strings.
82
+ *
83
+ * @param {string} key - Metadata key (lowercase, no whitespace).
84
+ * @param {string|Buffer} value - The value to store.
85
+ * @returns {Metadata} `this` for chaining.
86
+ *
87
+ * @example
88
+ * md.set('x-request-id', 'abc-123');
89
+ */
90
+ set(key, value)
91
+ {
92
+ key = this._validateKey(key);
93
+ this._validateValue(key, value);
94
+ this._map.set(key, [value]);
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Add a value to a metadata key without replacing existing values.
100
+ * Allows multi-valued metadata (e.g. multiple tags or roles).
101
+ *
102
+ * @param {string} key - Metadata key.
103
+ * @param {string|Buffer} value - Value to append.
104
+ * @returns {Metadata} `this` for chaining.
105
+ *
106
+ * @example
107
+ * md.add('x-roles', 'admin');
108
+ * md.add('x-roles', 'editor');
109
+ * md.getAll('x-roles'); // ['admin', 'editor']
110
+ */
111
+ add(key, value)
112
+ {
113
+ key = this._validateKey(key);
114
+ this._validateValue(key, value);
115
+ const existing = this._map.get(key);
116
+ if (existing) existing.push(value);
117
+ else this._map.set(key, [value]);
118
+ return this;
119
+ }
120
+
121
+ /**
122
+ * Get the first value for a metadata key.
123
+ *
124
+ * @param {string} key - Metadata key.
125
+ * @returns {string|Buffer|undefined} First value, or undefined if not set.
126
+ *
127
+ * @example
128
+ * md.get('x-request-id'); // 'abc-123'
129
+ */
130
+ get(key)
131
+ {
132
+ key = normalizeKey(key);
133
+ const arr = this._map.get(key);
134
+ return arr ? arr[0] : undefined;
135
+ }
136
+
137
+ /**
138
+ * Get all values for a metadata key.
139
+ *
140
+ * @param {string} key - Metadata key.
141
+ * @returns {Array<string|Buffer>} Array of values (empty if key not set).
142
+ *
143
+ * @example
144
+ * md.getAll('x-roles'); // ['admin', 'editor']
145
+ */
146
+ getAll(key)
147
+ {
148
+ key = normalizeKey(key);
149
+ return this._map.get(key) || [];
150
+ }
151
+
152
+ /**
153
+ * Check whether a metadata key has been set.
154
+ *
155
+ * @param {string} key - Metadata key.
156
+ * @returns {boolean}
157
+ */
158
+ has(key)
159
+ {
160
+ return this._map.has(normalizeKey(key));
161
+ }
162
+
163
+ /**
164
+ * Remove a metadata key and all its values.
165
+ *
166
+ * @param {string} key - Metadata key.
167
+ * @returns {boolean} `true` if the key existed and was removed.
168
+ */
169
+ remove(key)
170
+ {
171
+ return this._map.delete(normalizeKey(key));
172
+ }
173
+
174
+ /**
175
+ * Remove all metadata entries.
176
+ *
177
+ * @returns {Metadata} `this` for chaining.
178
+ */
179
+ clear()
180
+ {
181
+ this._map.clear();
182
+ return this;
183
+ }
184
+
185
+ /**
186
+ * Return the number of distinct metadata keys.
187
+ *
188
+ * @returns {number}
189
+ */
190
+ get size()
191
+ {
192
+ return this._map.size;
193
+ }
194
+
195
+ // -- Serialization -------------------------------------
196
+
197
+ /**
198
+ * Convert metadata to a plain object suitable for HTTP/2 headers/trailers.
199
+ * Multi-valued keys are joined with `, `. Binary values are base64-encoded.
200
+ *
201
+ * @returns {Object<string, string>} Header-compatible object.
202
+ *
203
+ * @example
204
+ * md.toHeaders();
205
+ * // { 'x-request-id': 'abc-123', 'x-roles': 'admin, editor' }
206
+ */
207
+ toHeaders()
208
+ {
209
+ const headers = {};
210
+ for (const [key, values] of this._map)
211
+ {
212
+ if (isBinaryKey(key))
213
+ {
214
+ // Binary keys: base64 encode each value, comma-join
215
+ headers[key] = values.map((v) =>
216
+ Buffer.isBuffer(v) ? v.toString('base64') : Buffer.from(String(v)).toString('base64')
217
+ ).join(', ');
218
+ }
219
+ else
220
+ {
221
+ headers[key] = values.join(', ');
222
+ }
223
+ }
224
+ return headers;
225
+ }
226
+
227
+ /**
228
+ * Merge entries from another Metadata instance or plain object.
229
+ *
230
+ * @param {Metadata|Object<string, string|string[]>} other - Source to merge from.
231
+ * @returns {Metadata} `this` for chaining.
232
+ */
233
+ merge(other)
234
+ {
235
+ if (other instanceof Metadata)
236
+ {
237
+ for (const [key, values] of other._map)
238
+ {
239
+ for (const v of values) this.add(key, v);
240
+ }
241
+ }
242
+ else if (other && typeof other === 'object')
243
+ {
244
+ for (const [key, val] of Object.entries(other))
245
+ {
246
+ if (Array.isArray(val))
247
+ {
248
+ for (const v of val) this.add(key, v);
249
+ }
250
+ else
251
+ {
252
+ this.add(key, val);
253
+ }
254
+ }
255
+ }
256
+ return this;
257
+ }
258
+
259
+ /**
260
+ * Create a shallow clone of this Metadata.
261
+ *
262
+ * @returns {Metadata} New Metadata instance with the same entries.
263
+ */
264
+ clone()
265
+ {
266
+ const md = new Metadata({ maxSize: this._maxSize });
267
+ for (const [key, values] of this._map)
268
+ {
269
+ md._map.set(key, values.slice());
270
+ }
271
+ return md;
272
+ }
273
+
274
+ /**
275
+ * Iterate over all entries as `[key, value]` pairs (one entry per value).
276
+ *
277
+ * @yields {[string, string|Buffer]}
278
+ */
279
+ *[Symbol.iterator]()
280
+ {
281
+ for (const [key, values] of this._map)
282
+ {
283
+ for (const v of values) yield [key, v];
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Return all entries as an array of `[key, value]` pairs.
289
+ *
290
+ * @returns {Array<[string, string|Buffer]>}
291
+ */
292
+ entries()
293
+ {
294
+ const result = [];
295
+ for (const pair of this) result.push(pair);
296
+ return result;
297
+ }
298
+
299
+ /**
300
+ * Return all distinct keys.
301
+ *
302
+ * @returns {string[]}
303
+ */
304
+ keys()
305
+ {
306
+ return Array.from(this._map.keys());
307
+ }
308
+
309
+ // -- Validation ----------------------------------------
310
+
311
+ /**
312
+ * Validate and normalize a metadata key.
313
+ * @private
314
+ * @param {string} key
315
+ * @returns {string} Normalized key.
316
+ */
317
+ _validateKey(key)
318
+ {
319
+ if (typeof key !== 'string')
320
+ throw new TypeError('Metadata key must be a string');
321
+
322
+ key = key.toLowerCase().trim();
323
+
324
+ if (key.length === 0)
325
+ throw new Error('Metadata key must not be empty');
326
+
327
+ if (key.length > MAX_KEY_LENGTH)
328
+ throw new Error(`Metadata key exceeds max length (${MAX_KEY_LENGTH})`);
329
+
330
+ if (RESERVED.has(key))
331
+ throw new Error(`Cannot set reserved pseudo-header: ${key}`);
332
+
333
+ if (GRPC_INTERNAL.has(key))
334
+ throw new Error(`Cannot set gRPC internal header: ${key}`);
335
+
336
+ // Keys must be lowercase ASCII alphanumeric + hyphen + underscore + period
337
+ if (!/^[a-z0-9_.\-]+$/.test(key))
338
+ throw new Error(`Invalid metadata key: "${key}" (must be lowercase ASCII alphanumeric/hyphen/underscore/period)`);
339
+
340
+ return key;
341
+ }
342
+
343
+ /**
344
+ * Validate a metadata value.
345
+ * @private
346
+ * @param {string} key
347
+ * @param {string|Buffer} value
348
+ */
349
+ _validateValue(key, value)
350
+ {
351
+ if (isBinaryKey(key))
352
+ {
353
+ if (!Buffer.isBuffer(value) && typeof value !== 'string')
354
+ throw new TypeError(`Binary metadata key "${key}" requires a Buffer or string value`);
355
+ }
356
+ else
357
+ {
358
+ if (typeof value !== 'string')
359
+ throw new TypeError(`Metadata key "${key}" requires a string value`);
360
+
361
+ // ASCII printable check (0x20-0x7E)
362
+ for (let i = 0; i < value.length; i++)
363
+ {
364
+ const c = value.charCodeAt(i);
365
+ if (c < 0x20 || c > 0x7E)
366
+ throw new Error(`Non-ASCII character in metadata value for key "${key}" at position ${i}`);
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ // -- Static Helpers ----------------------------------------
373
+
374
+ /**
375
+ * Create a Metadata instance from HTTP/2 headers, extracting only user metadata
376
+ * (skipping pseudo-headers, gRPC internal headers, and standard HTTP headers).
377
+ *
378
+ * @param {Object<string, string|string[]>} headers - HTTP/2 headers object.
379
+ * @param {object} [opts] - Options.
380
+ * @param {number} [opts.maxSize=8192] - Maximum metadata size.
381
+ * @returns {Metadata} Populated metadata instance.
382
+ *
383
+ * @example
384
+ * const md = Metadata.fromHeaders(stream.headers);
385
+ */
386
+ Metadata.fromHeaders = function fromHeaders(headers, opts)
387
+ {
388
+ const md = new Metadata(opts);
389
+ if (!headers || typeof headers !== 'object') return md;
390
+
391
+ for (const [key, rawValue] of Object.entries(headers))
392
+ {
393
+ const k = key.toLowerCase();
394
+ if (RESERVED.has(k) || GRPC_INTERNAL.has(k)) continue;
395
+ // Skip standard HTTP headers that aren't metadata
396
+ if (k === 'accept' || k === 'accept-encoding' || k === 'content-length') continue;
397
+
398
+ try
399
+ {
400
+ if (isBinaryKey(k))
401
+ {
402
+ // Base64-decode binary values
403
+ const values = String(rawValue).split(',').map((s) => s.trim());
404
+ for (const v of values)
405
+ {
406
+ md._map.set(k, md._map.get(k) || []);
407
+ md._map.get(k).push(Buffer.from(v, 'base64'));
408
+ }
409
+ }
410
+ else
411
+ {
412
+ const values = String(rawValue).split(',').map((s) => s.trim());
413
+ for (const v of values)
414
+ {
415
+ md._map.set(k, md._map.get(k) || []);
416
+ md._map.get(k).push(v);
417
+ }
418
+ }
419
+ }
420
+ catch (e)
421
+ {
422
+ log.warn('skipping invalid metadata key=%s: %s', k, e.message);
423
+ }
424
+ }
425
+
426
+ return md;
427
+ };
428
+
429
+ // -- Utility Functions -------------------------------------
430
+
431
+ /**
432
+ * Check if a metadata key is a binary key (ends with `-bin`).
433
+ *
434
+ * @param {string} key - Metadata key.
435
+ * @returns {boolean}
436
+ */
437
+ function isBinaryKey(key)
438
+ {
439
+ return key.endsWith('-bin');
440
+ }
441
+
442
+ /**
443
+ * Normalize a metadata key to lowercase.
444
+ *
445
+ * @param {string} key
446
+ * @returns {string}
447
+ */
448
+ function normalizeKey(key)
449
+ {
450
+ return typeof key === 'string' ? key.toLowerCase().trim() : '';
451
+ }
452
+
453
+ module.exports = {
454
+ Metadata,
455
+ isBinaryKey,
456
+ normalizeKey,
457
+ RESERVED,
458
+ GRPC_INTERNAL,
459
+ MAX_KEY_LENGTH,
460
+ DEFAULT_MAX_METADATA_SIZE,
461
+ };