bgpkit-parser 0.21.0

MRT/BGP/BMP data processing library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
'use strict';

const fs = require('fs');
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const wasm = require('./nodejs/bgpkit_parser.js');

// ── Low-level parsing functions ──────────────────────────────────────

/**
 * Parse an OpenBMP-wrapped BMP message (e.g. from the RouteViews Kafka stream).
 *
 * Returns null for non-router OpenBMP frames (collector heartbeats).
 *
 * @param {Uint8Array} data - Raw OpenBMP message bytes
 * @returns {object|null} Parsed BMP message with discriminated `type` field
 */
function parseOpenBmpMessage(data) {
  const json = wasm.parseOpenBmpMessage(data);
  return json ? JSON.parse(json) : null;
}

/**
 * Parse a raw BMP message (no OpenBMP wrapper).
 *
 * @param {Uint8Array} data - Raw BMP message bytes
 * @param {number} timestamp - Collection time in seconds since Unix epoch
 * @returns {object} Parsed BMP message with discriminated `type` field
 */
function parseBmpMessage(data, timestamp) {
  return JSON.parse(wasm.parseBmpMessage(data, timestamp));
}

/**
 * Parse a single BGP UPDATE message into BGP elements.
 *
 * Expects a full BGP message including the 16-byte marker, 2-byte length,
 * and 1-byte type header. Assumes 4-byte ASN encoding.
 *
 * The returned elements have timestamp=0 and unspecified peer IP/ASN since
 * those are not part of the BGP message itself.
 *
 * @param {Uint8Array} data - Raw BGP message bytes
 * @returns {object[]} Array of BgpElem objects
 */
function parseBgpUpdate(data) {
  return JSON.parse(wasm.parseBgpUpdate(data));
}

/**
 * Parse a RIS Live WebSocket message using its JSON-projected UPDATE fields.
 *
 * No `includeRaw` subscription option required, but the JSON projection
 * exposes only a subset of BGP path attributes (path, community, origin,
 * med, aggregator, announcements, withdrawals).
 *
 * @param {string} message - RIS Live WebSocket text payload (`ris_message` envelope)
 * @returns {object[]} Array of BgpElem objects (empty for non-UPDATE messages)
 */
function parseRisLiveMessageJson(message) {
  return JSON.parse(wasm.parseRisLiveMessageJson(message));
}

/**
 * Parse a RIS Live WebSocket message from its hex `data.raw` BGP wire bytes.
 *
 * Requires a subscription with `socketOptions.includeRaw = true`. Preserves
 * every path attribute of the UPDATE (large/extended communities, OTC, local
 * pref, originator ID, cluster list, AIGP, ...) plus RFC 7606 validation
 * warnings, in addition to the same elems as parseRisLiveMessageJson().
 *
 * @param {string} message - RIS Live WebSocket text payload (`ris_message` envelope)
 * @returns {{ meta: object, elems: object[], attributes: object[], validationWarnings: object[] }}
 */
function parseRisLiveMessageRaw(message) {
  return JSON.parse(wasm.parseRisLiveMessageRaw(message));
}

/**
 * Parse a single BGP UPDATE message with full attribute fidelity.
 *
 * Like parseBgpUpdate() but additionally returns every path attribute
 * (including the ones the elem projection drops, e.g. originator ID,
 * cluster list, AIGP, raw-retained BGPSEC_PATH/ATTR_SET) and RFC 7606
 * validation warnings.
 *
 * @param {Uint8Array} data - Raw BGP message bytes (with header)
 * @returns {{ elems: object[], attributes: object[], validationWarnings: object[] }}
 */
function parseBgpUpdateFull(data) {
  return JSON.parse(wasm.parseBgpUpdateFull(data));
}

/**
 * Dissect a single BGP message into a Wireshark-style field tree.
 *
 * Every node of the returned tree carries its byte range (`offset`/`length`
 * relative to the message start), so a UI can highlight the bytes behind any
 * protocol field and vice versa. Attribute values are dissected one level
 * deep (AS_PATH segments, community entries, MP_REACH structure, fixed u32
 * fields).
 *
 * Dissection is best effort: truncated or malformed input yields a partial
 * tree showing how far the structure could be walked, never an error.
 *
 * @param {Uint8Array} data - Raw BGP message bytes (with header)
 * @param {boolean} [fourByteAsn=true] - 4-octet (modern default) vs 2-octet
 *   AS number rendering inside AS_PATH/AGGREGATOR
 * @returns {object} DissectionNode tree
 */
function dissectBgpMessage(data, fourByteAsn = true) {
  return JSON.parse(wasm.dissectBgpMessage(data, fourByteAsn));
}

// ── Streaming MRT record parsing ─────────────────────────────────────

// MRT common header: timestamp(4) + type(2) + subtype(2) + length(4) = 12 bytes.
const MRT_HEADER_LEN = 12;

/**
 * Read the MRT record length from a 12-byte header.
 * Returns the total record size (header + body) or -1 if not enough data.
 */
function mrtRecordSize(data, offset) {
  if (offset + MRT_HEADER_LEN > data.length) return -1;
  const bodyLen =
    (data[offset + 8] << 24) |
    (data[offset + 9] << 16) |
    (data[offset + 10] << 8) |
    data[offset + 11];
  return MRT_HEADER_LEN + (bodyLen >>> 0);
}

/**
 * Parse a single MRT record from the start of a buffer.
 *
 * Only sends the bytes of that one record to WASM, so even multi-GB files
 * work without exceeding WASM's 4 GB memory limit.
 *
 * @param {Uint8Array} data - Remaining decompressed MRT bytes
 * @returns {{ elems: object[], bytesRead: number } | null}
 */
function parseMrtRecord(data) {
  const size = mrtRecordSize(data, 0);
  if (size < 0 || size > data.length) return null;
  const recordBytes = data.subarray(0, size);
  const json = wasm.parseMrtRecord(recordBytes);
  if (!json) return null;
  const result = JSON.parse(json);
  result.bytesRead = size;
  return result;
}

/**
 * Dissect one MRT record from the start of the buffer into a field tree.
 *
 * The tree's byte offsets cover the whole record: common header fields, the
 * BGP4MP subheader, and the embedded BGP message. Use `bytesRead` to slice
 * off the record before dissecting the next one, exactly like
 * parseMrtRecord().
 *
 * @param {Uint8Array} data - Remaining decompressed MRT bytes
 * @returns {{ tree: object, bytesRead: number } | null}
 */
function dissectMrtRecord(data) {
  const size = mrtRecordSize(data, 0);
  if (size < 0 || size > data.length) return null;
  const recordBytes = data.subarray(0, size);
  const json = wasm.dissectMrtRecord(recordBytes);
  if (!json) return null;
  const result = JSON.parse(json);
  result.bytesRead = size;
  return result;
}

/**
 * Reset the internal MRT parser state. Call before parsing a new file
 * with `parseMrtRecord` to clear the PeerIndexTable from a previous file.
 */
function resetMrtParser() {
  wasm.resetMrtParser();
}

/**
 * Generator that yields parsed MRT records one at a time.
 *
 * Automatically resets parser state before starting, so the PeerIndexTable
 * from a previous file does not leak. Each iteration sends only one record's
 * bytes to WASM, keeping memory usage constant regardless of total file size.
 *
 * @param {Uint8Array} data - Decompressed MRT file bytes
 * @yields {{ elems: object[], bytesRead: number }}
 */
function* parseMrtRecords(data) {
  wasm.resetMrtParser();
  let offset = 0;
  while (offset < data.length) {
    const size = mrtRecordSize(data, offset);
    if (size < 0 || offset + size > data.length) break;
    const recordBytes = data.subarray(offset, offset + size);
    const json = wasm.parseMrtRecord(recordBytes);
    if (!json) break;
    const result = JSON.parse(json);
    result.bytesRead = size;
    yield result;
    offset += size;
  }
}

// ── I/O helpers (oneio-style transparent source + compression) ───────

/**
 * Try to load an optional bzip2 decompressor.
 * Supports: 'unbzip2-stream' (streaming), 'seek-bzip' (sync), 'bz2' (sync).
 */
let _bz2Module = undefined; // undefined = not checked, null = not available
function getBz2() {
  if (_bz2Module !== undefined) return _bz2Module;
  for (const name of ['unbzip2-stream', 'seek-bzip', 'bz2']) {
    try {
      _bz2Module = { name, mod: require(name) };
      return _bz2Module;
    } catch {}
  }
  _bz2Module = null;
  return null;
}

/**
 * Detect compression type from a file path or URL.
 * @param {string} pathOrUrl
 * @returns {'gz' | 'bz2' | 'xz' | 'none'}
 */
function detectCompression(pathOrUrl) {
  const name = pathOrUrl.split('?')[0].split('#')[0]; // strip query/fragment
  if (name.endsWith('.gz')) return 'gz';
  if (name.endsWith('.bz2')) return 'bz2';
  if (name.endsWith('.xz')) return 'xz';
  return 'none';
}

/**
 * Open an HTTP(S) URL and return a readable stream, following redirects.
 * @param {string} url
 * @returns {Promise<import('stream').Readable>}
 */
function httpGet(url) {
  return new Promise((resolve, reject) => {
    const lib = url.startsWith('https') ? https : http;
    lib
      .get(url, (res) => {
        if (res.statusCode === 301 || res.statusCode === 302) {
          httpGet(res.headers.location).then(resolve, reject);
          return;
        }
        if (res.statusCode !== 200) {
          reject(new Error(`HTTP ${res.statusCode} for ${url}`));
          return;
        }
        resolve(res);
      })
      .on('error', reject);
  });
}

/**
 * Collect a readable stream into a single Buffer.
 * @param {import('stream').Readable} stream
 * @returns {Promise<Buffer>}
 */
function collectStream(stream) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('end', () => resolve(Buffer.concat(chunks)));
    stream.on('error', reject);
  });
}

/**
 * Decompress a buffer based on the detected compression type.
 * @param {Buffer} buf
 * @param {'gz' | 'bz2' | 'xz' | 'none'} compression
 * @returns {Buffer}
 */
function decompressSync(buf, compression) {
  if (compression === 'gz') {
    return zlib.gunzipSync(buf);
  }
  if (compression === 'bz2') {
    const bz2 = getBz2();
    if (!bz2) {
      throw new Error(
        'bzip2 decompression requires an optional dependency. ' +
          'Install one of: npm install unbzip2-stream, npm install seek-bzip, npm install bz2'
      );
    }
    if (bz2.name === 'seek-bzip') {
      return Buffer.from(bz2.mod.decode(buf));
    }
    if (bz2.name === 'bz2') {
      const decompress = bz2.mod.decompress || bz2.mod;
      return Buffer.from(decompress(buf));
    }
    // unbzip2-stream: streaming, need to pipe through
    throw new Error(
      'unbzip2-stream does not support sync decompression. ' +
        'Use streamMrtFrom() instead, or install seek-bzip or bz2.'
    );
  }
  if (compression === 'xz') {
    throw new Error('xz decompression is not yet supported in the WASM package');
  }
  return buf;
}

/**
 * Create a decompression stream for the given compression type.
 * @param {'gz' | 'bz2' | 'xz' | 'none'} compression
 * @returns {import('stream').Transform | null}
 */
function decompressStream(compression) {
  if (compression === 'gz') {
    return zlib.createGunzip();
  }
  if (compression === 'bz2') {
    const bz2 = getBz2();
    if (!bz2) {
      throw new Error(
        'bzip2 decompression requires an optional dependency. ' +
          'Install one of: npm install unbzip2-stream, npm install seek-bzip, npm install bz2'
      );
    }
    if (bz2.name === 'unbzip2-stream') {
      return bz2.mod();
    }
    // seek-bzip and bz2 don't have streaming APIs; collect and decompress
    return null;
  }
  return null;
}

/**
 * Open an MRT file from a local path or URL, automatically decompressing
 * based on the file extension (.gz, .bz2).
 *
 * This is the JS equivalent of oneio's `get_reader(path)` — it makes the
 * source (local file vs HTTP) and compression format transparent.
 *
 * @param {string} pathOrUrl - Local file path or HTTP(S) URL
 * @returns {Promise<Buffer>} Decompressed MRT file bytes
 */
async function openMrt(pathOrUrl) {
  const compression = detectCompression(pathOrUrl);
  const isUrl =
    pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://');

  if (isUrl) {
    const rawStream = await httpGet(pathOrUrl);
    const decomp = decompressStream(compression);
    if (decomp) {
      return collectStream(rawStream.pipe(decomp));
    }
    // No streaming decompressor available — collect then decompress sync
    const raw = await collectStream(rawStream);
    return decompressSync(raw, compression);
  }

  // Local file
  const raw = fs.readFileSync(pathOrUrl);
  return decompressSync(raw, compression);
}

/**
 * Async generator that streams MRT records from a local path or URL.
 *
 * Handles fetching, decompression (gz/bz2), and incremental parsing.
 * Each yield returns one MRT record's elements, keeping WASM memory
 * usage constant regardless of file size.
 *
 * @param {string} pathOrUrl - Local file path or HTTP(S) URL
 * @yields {{ elems: object[], bytesRead: number }}
 *
 * @example
 * for await (const { elems } of streamMrtFrom("https://archive.routeviews.org/.../rib.20250101.0000.bz2")) {
 *   for (const elem of elems) {
 *     console.log(elem.prefix, elem.as_path);
 *   }
 * }
 */
async function* streamMrtFrom(pathOrUrl) {
  const raw = await openMrt(pathOrUrl);
  yield* parseMrtRecords(raw);
}

module.exports = {
  // Low-level byte parsers (all platforms)
  parseOpenBmpMessage,
  parseBmpMessage,
  parseBgpUpdate,
  parseBgpUpdateFull,
  dissectBgpMessage,
  parseRisLiveMessageJson,
  parseRisLiveMessageRaw,
  parseMrtRecords,
  parseMrtRecord,
  dissectMrtRecord,
  resetMrtParser,

  // Node.js I/O helpers
  openMrt,
  streamMrtFrom,
};