diff --git a/.gitignore b/.gitignore index 6325d92..e11a14b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,12 @@ examples/debug-file.rs # local benchmark results benchmarks docs/ -*.md \ No newline at end of file +*.md +!examples/**/*.md +!src/wasm/**/*.md + +# WASM build artifacts +pkg/ +pkg-*/ +node_modules/ +.wrangler/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 666d1cb..fbb933d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ All notable changes to this project will be documented in this file. ### New features +* **WebAssembly (WASM) support (experimental)**: New `wasm` feature flag compiles the BMP/BGP/MRT parsing core to WebAssembly for use in JavaScript environments. Published as [`@bgpkit/parser`](https://www.npmjs.com/package/@bgpkit/parser) on npm with support for Node.js (CommonJS), bundlers (ES modules), and browsers/workers (ES modules with manual init). This feature is experimental and the API may change in future releases. + - Core parsing functions (all platforms): + - `parseOpenBmpMessage(data)`: parses OpenBMP-wrapped BMP frames (e.g. RouteViews Kafka stream) + - `parseBmpMessage(data, timestamp)`: parses raw BMP frames (without an OpenBMP header) + - `parseBgpUpdate(data)`: parses a single BGP UPDATE message into `BgpElem[]` + - `parseMrtRecords(data)`: streaming generator that yields MRT records one at a time from a decompressed buffer + - `parseMrtRecord(data)` / `resetMrtParser()`: low-level MRT record parsing + - Node.js I/O helpers: + - `streamMrtFrom(pathOrUrl)`: fetch, decompress (gz/bz2), and stream-parse MRT records from a URL or local file + - `openMrt(pathOrUrl)`: fetch and decompress MRT data into a `Buffer` + - Supports gzip (RIPE RIS) and bzip2 (RouteViews, requires optional `seek-bzip` dependency) compression + - Multi-target build script (`src/wasm/build.sh`) produces nodejs, bundler, and web targets in a single npm package + - JS wrapper handles JSON deserialization; TypeScript types included + - Node.js examples in `examples/wasm/`: Kafka OpenBMP stream consumer and MRT file parser + - Browser-based MRT explorer demo: [mrt-explorer.labs.bgpkit.com](https://mrt-explorer.labs.bgpkit.com/) + * **`BgpElem::peer_bgp_id` field**: `BgpElem` now exposes an optional `peer_bgp_id: Option` containing the peer's BGP Identifier (Router ID) when available. Populated from the PEER_INDEX_TABLE in TableDumpV2/RIB records; `None` for BGP4MP records. ### Performance improvements diff --git a/Cargo.toml b/Cargo.toml index 4046fde..e5b5526 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,13 @@ name = "bgpkit-parser" path = "src/bin/main.rs" required-features = ["cli"] +# cdylib is required by wasm-pack for WASM builds; rlib is the default for +# normal Rust library usage. Both are listed so the crate works for native +# consumers and wasm-pack alike. +[lib] +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + [dependencies] ############## @@ -44,6 +51,7 @@ oneio = { version = "0.20.0", default-features = false, features = ["http", "gz" regex = { version = "1", optional = true } # used in parser filter chrono = { version = "0.4.38", optional = true } # parser filter serde_json = { version = "1.0", optional = true } # RIS Live parsing +wasm-bindgen = { version = "0.2", optional = true } #################### # CLI dependencies # @@ -76,6 +84,15 @@ rislive = [ "serde_json", "hex", ] + +# WebAssembly bindings for browser/Node.js usage. +# Build with: wasm-pack build --target nodejs --no-default-features --features wasm +wasm = [ + "parser", + "serde", + "serde_json", + "dep:wasm-bindgen", +] serde = [ "dep:serde", "ipnet/serde", diff --git a/examples/wasm/kafka-openbmp-stream/.gitignore b/examples/wasm/kafka-openbmp-stream/.gitignore new file mode 100644 index 0000000..2d26f7b --- /dev/null +++ b/examples/wasm/kafka-openbmp-stream/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +pkg/ +package-lock.json diff --git a/examples/wasm/kafka-openbmp-stream/README.md b/examples/wasm/kafka-openbmp-stream/README.md new file mode 100644 index 0000000..9e12fa1 --- /dev/null +++ b/examples/wasm/kafka-openbmp-stream/README.md @@ -0,0 +1,59 @@ +# RouteViews Kafka Stream — Node.js Example + +> **Experimental**: The `@bgpkit/parser` npm package is experimental. The API +> surface, output format, and build process may change in future releases. + +This example consumes the [RouteViews](http://www.routeviews.org/routeviews/) +real-time Kafka stream of OpenBMP messages and parses them into JSON using +[`@bgpkit/parser`](https://www.npmjs.com/package/@bgpkit/parser). + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 18 + +## Run + +```sh +cd examples/wasm/kafka-openbmp-stream +npm install +npm start +``` + +## Configuration + +Edit the constants at the top of `kafka-stream.js`: + +| Variable | Default | Description | +|---|---|---| +| `BROKER` | `stream.routeviews.org:9092` | Kafka broker address | +| `TOPIC_PATTERN` | `/^routeviews\.amsix\..+\.bmp_raw$/` | Regex to filter Kafka topics | +| `GROUP_ID` | `bgpkit-parser-nodejs-example` | Kafka consumer group ID | + +### Selecting collectors + +Topics follow the naming pattern `routeviews...bmp_raw`: + +```js +// All AMS-IX topics +const TOPIC_PATTERN = /^routeviews\.amsix\..+\.bmp_raw$/; + +// A specific collector/peer +const TOPIC_PATTERN = /^routeviews\.amsix\.ams\.6777\.bmp_raw$/; + +// All collectors (high volume!) +const TOPIC_PATTERN = /^routeviews\..+\.bmp_raw$/; +``` + +## Output + +Each line is a JSON object representing a parsed BMP message. RouteMonitoring +messages include an `elems` array of BGP elements (announcements/withdrawals). + +## How it works + +1. `@bgpkit/parser` is a WebAssembly build of bgpkit-parser's BMP/BGP parsing + core, published as an npm package. +2. `parseOpenBmpMessage(bytes)` accepts raw Kafka message bytes (an OpenBMP + header + BMP frame) and returns a parsed JavaScript object. +3. [KafkaJS](https://kafka.js.org/) is used to consume messages from the + RouteViews broker. diff --git a/examples/wasm/kafka-openbmp-stream/kafka-stream.js b/examples/wasm/kafka-openbmp-stream/kafka-stream.js new file mode 100644 index 0000000..4a9cf10 --- /dev/null +++ b/examples/wasm/kafka-openbmp-stream/kafka-stream.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Route-views Kafka stream consumer using @bgpkit/parser. + * + * Prerequisites: + * 1. Install Node.js dependencies: + * npm install + * 2. Run: + * npm start + * + * The RouteViews Kafka stream is publicly accessible at stream.routeviews.org:9092. + * Topics follow the pattern: routeviews...bmp_raw + * Each message value is an OpenBMP-wrapped BMP frame (binary). + */ + +const { Kafka, logLevel } = require('kafkajs'); +const { parseOpenBmpMessage } = require('@bgpkit/parser'); + +// ── Configuration ──────────────────────────────────────────────────────────── + +const BROKER = 'stream.routeviews.org:9092'; + +// Regex filter applied to Kafka topic names. Adjust to taste: +// All collectors: /^routeviews\..+\.bmp_raw$/ +// Specific collector: /^routeviews\.amsix\.ams\..+\.bmp_raw$/ +const TOPIC_PATTERN = /^routeviews\.amsix\..+\.bmp_raw$/; + +// Consumer group ID. Change this to start reading from the latest offset +// in a fresh group, or reuse a group to resume from where you left off. +const GROUP_ID = 'bgpkit-parser-nodejs-example'; + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function run() { + const kafka = new Kafka({ + clientId: 'bgpkit-parser-example', + brokers: [BROKER], + logLevel: logLevel.NOTHING, + }); + + const admin = kafka.admin(); + await admin.connect(); + const allTopics = await admin.listTopics(); + await admin.disconnect(); + + const topics = allTopics.filter((t) => TOPIC_PATTERN.test(t)); + if (topics.length === 0) { + console.error(`No topics matching ${TOPIC_PATTERN} found on ${BROKER}`); + process.exit(1); + } + process.stderr.write(`Subscribed to ${topics.length} topics on ${BROKER}\n`); + + const consumer = kafka.consumer({ + groupId: GROUP_ID, + sessionTimeout: 30000, + }); + await consumer.connect(); + + for (const topic of topics) { + await consumer.subscribe({ topic, fromBeginning: false }); + } + + await consumer.run({ + eachMessage: async ({ message }) => { + if (!message.value) return; + + let msg; + try { + msg = parseOpenBmpMessage(message.value); + } catch { + return; + } + + if (!msg) return; + + console.log(JSON.stringify(msg)); + }, + }); +} + +run().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/examples/wasm/kafka-openbmp-stream/package.json b/examples/wasm/kafka-openbmp-stream/package.json new file mode 100644 index 0000000..04069c0 --- /dev/null +++ b/examples/wasm/kafka-openbmp-stream/package.json @@ -0,0 +1,17 @@ +{ + "name": "bgpkit-parser-kafka-example", + "version": "1.0.0", + "description": "Route-views Kafka stream consumer using @bgpkit/parser (WebAssembly)", + "main": "kafka-stream.js", + "scripts": { + "start": "node kafka-stream.js", + "start:quiet": "node kafka-stream.js 2>/dev/null" + }, + "dependencies": { + "@bgpkit/parser": "^0.15.0", + "kafkajs": "^2.2.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/wasm/parse-mrt-file/.gitignore b/examples/wasm/parse-mrt-file/.gitignore new file mode 100644 index 0000000..085a82d --- /dev/null +++ b/examples/wasm/parse-mrt-file/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +*.gz +*.bz2 +*.mrt diff --git a/examples/wasm/parse-mrt-file/README.md b/examples/wasm/parse-mrt-file/README.md new file mode 100644 index 0000000..ff43bc1 --- /dev/null +++ b/examples/wasm/parse-mrt-file/README.md @@ -0,0 +1,51 @@ +# Parse MRT File — Node.js Example + +> **Experimental**: The `@bgpkit/parser` npm package is experimental. The API +> surface, output format, and build process may change in future releases. + +This example parses an MRT file into BGP elements using +[`@bgpkit/parser`](https://www.npmjs.com/package/@bgpkit/parser) and outputs +one JSON object per line. It accepts both URLs and local files. + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 18 +- For bz2-compressed files (RouteViews): `npm install seek-bzip` + +## Run + +```sh +cd examples/wasm/parse-mrt-file +npm install +npm install seek-bzip # optional, for .bz2 files + +# Parse directly from a URL (fetches and decompresses in memory) +node parse-mrt.js https://data.ris.ripe.net/rrc06/2026.03/updates.20260322.2105.gz + +# Or parse a local file +node parse-mrt.js updates.20260322.2105.gz +``` + +## Output + +Each line is a JSON object representing a single BGP element: + +```json +{"timestamp":1742677500.0,"type":"ANNOUNCE","peer_ip":"2001:7f8:4::...","peer_asn":6939,...} +{"timestamp":1742677500.0,"type":"WITHDRAW","peer_ip":"80.249.211.155","peer_asn":34549,...} +``` + +You can pipe the output through `jq` for pretty-printing or filtering: + +```sh +URL=https://data.ris.ripe.net/rrc06/2026.03/updates.20260322.2105.gz + +# Pretty-print first element +node parse-mrt.js $URL | head -1 | jq . + +# Count announcements vs withdrawals +node parse-mrt.js $URL | jq -r '.type' | sort | uniq -c + +# Filter by prefix +node parse-mrt.js $URL | jq -r 'select(.prefix == "1.0.0.0/24")' +``` diff --git a/examples/wasm/parse-mrt-file/package.json b/examples/wasm/parse-mrt-file/package.json new file mode 100644 index 0000000..ad7a2a5 --- /dev/null +++ b/examples/wasm/parse-mrt-file/package.json @@ -0,0 +1,16 @@ +{ + "name": "bgpkit-parser-mrt-example", + "version": "1.0.0", + "description": "Parse MRT files into BGP elements using @bgpkit/parser (WebAssembly)", + "main": "parse-mrt.js", + "scripts": { + "start": "node parse-mrt.js" + }, + "dependencies": { + "@bgpkit/parser": "^0.15.0", + "seek-bzip": "^2.0.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/wasm/parse-mrt-file/parse-mrt.js b/examples/wasm/parse-mrt-file/parse-mrt.js new file mode 100644 index 0000000..334b63d --- /dev/null +++ b/examples/wasm/parse-mrt-file/parse-mrt.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Parse an MRT file and output BGP elements as JSON, one per line. + * + * Usage: + * node parse-mrt.js + * node parse-mrt.js https://data.ris.ripe.net/rrc06/2026.03/updates.20260322.2105.gz + * node parse-mrt.js https://archive.routeviews.org/route-views.amsix/bgpdata/2025.01/UPDATES/updates.20250101.0000.bz2 + * node parse-mrt.js updates.20260322.2105.gz + * + * Supports .gz (gzip) and .bz2 (bzip2, requires: npm install seek-bzip) compressed + * and uncompressed MRT files. URLs and local paths are both supported. + */ + +const { streamMrtFrom } = require('@bgpkit/parser'); + +const input = process.argv[2]; + +if (!input) { + console.error('Usage: node parse-mrt.js '); + console.error(' node parse-mrt.js https://data.ris.ripe.net/rrc06/2026.03/updates.20260322.2105.gz'); + console.error(' node parse-mrt.js updates.20260322.2105.gz'); + process.exit(1); +} + +async function main() { + process.stderr.write(`Parsing ${input}...\n`); + + let count = 0; + + for await (const { elems } of streamMrtFrom(input)) { + for (const elem of elems) { + console.log(JSON.stringify(elem)); + count++; + } + } + + process.stderr.write(`Output ${count} BGP elements\n`); +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/src/lib.rs b/src/lib.rs index 6948100..5e1acb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -834,6 +834,8 @@ pub mod error; pub mod models; #[cfg(feature = "parser")] pub mod parser; +#[cfg(feature = "wasm")] +pub mod wasm; pub use models::BgpElem; pub use models::MrtRecord; diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 0000000..c099c4e --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,633 @@ +//! WebAssembly bindings for BMP/BGP/MRT message parsing. +//! +//! This module is compiled only when the `wasm` feature is enabled. Use +//! [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) to build the package: +//! +//! ```sh +//! # Node.js target (CommonJS, synchronous WASM loading) +//! wasm-pack build --target nodejs --no-default-features --features wasm +//! +//! # Bundler target (for webpack/vite/rollup) +//! wasm-pack build --target bundler --no-default-features --features wasm +//! ``` +//! +//! ## Exported functions +//! +//! - [`parseOpenBmpMessage`](parse_openbmp_message) — parse OpenBMP-wrapped BMP +//! messages (e.g. from the RouteViews Kafka stream) +//! - [`parseBmpMessage`](parse_bmp_message) — parse raw BMP messages +//! - [`parseMrtRecord`](parse_mrt_record_wasm) — parse a single MRT record +//! (for incremental/streaming parsing) +//! - [`resetMrtParser`](reset_mrt_parser) — reset MRT parser state between files +//! - [`parseBgpUpdate`](parse_bgp_update) — parse a single BGP UPDATE message + +use crate::models::*; +use crate::parser::bgp::messages::parse_bgp_message; +use crate::parser::bmp::error::ParserBmpError; +use crate::parser::bmp::messages::*; +use crate::parser::bmp::{parse_bmp_msg, parse_openbmp_header}; +use crate::parser::mrt::mrt_elem::Elementor; +use crate::parser::mrt::mrt_record::parse_mrt_record; +use bytes::Bytes; +use std::io::Cursor; +use std::net::{IpAddr, Ipv4Addr}; +use wasm_bindgen::prelude::*; + +// ── Serialization types ────────────────────────────────────────────────────── +// +// These structs use camelCase `#[serde(rename)]` to produce JS-friendly JSON. +// The `WasmBmpMessage` enum is internally tagged on `"type"` so JS consumers +// can discriminate with `msg.type === "RouteMonitoring"` etc. + +#[derive(serde::Serialize)] +#[serde(tag = "type")] +enum WasmBmpMessage { + RouteMonitoring { + #[serde(flatten)] + base: WasmMessageBase, + #[serde(rename = "peerHeader")] + peer_header: WasmPeerHeader, + elems: Vec, + }, + PeerUpNotification { + #[serde(flatten)] + base: WasmMessageBase, + #[serde(rename = "peerHeader")] + peer_header: WasmPeerHeader, + #[serde(rename = "localIp")] + local_ip: String, + #[serde(rename = "localPort")] + local_port: u16, + #[serde(rename = "remotePort")] + remote_port: u16, + }, + PeerDownNotification { + #[serde(flatten)] + base: WasmMessageBase, + #[serde(rename = "peerHeader")] + peer_header: WasmPeerHeader, + reason: String, + }, + InitiationMessage { + #[serde(flatten)] + base: WasmMessageBase, + tlvs: Vec, + }, + TerminationMessage { + #[serde(flatten)] + base: WasmMessageBase, + tlvs: Vec, + }, + StatisticsReport { + #[serde(flatten)] + base: WasmMessageBase, + #[serde(rename = "peerHeader")] + peer_header: WasmPeerHeader, + }, + RouteMirroringMessage { + #[serde(flatten)] + base: WasmMessageBase, + #[serde(rename = "peerHeader")] + peer_header: WasmPeerHeader, + }, +} + +#[derive(serde::Serialize)] +struct WasmMessageBase { + #[serde(rename = "openBmpHeader")] + openbmp_header: Option, + timestamp: f64, +} + +#[derive(serde::Serialize)] +struct WasmOpenBmpHeader { + #[serde(rename = "routerIp")] + router_ip: String, + #[serde(rename = "routerGroup")] + router_group: Option, + #[serde(rename = "adminId")] + admin_id: String, + timestamp: f64, +} + +#[derive(serde::Serialize)] +struct WasmPeerHeader { + #[serde(rename = "peerIp")] + peer_ip: String, + #[serde(rename = "peerAsn")] + peer_asn: u32, + #[serde(rename = "peerBgpId")] + peer_bgp_id: String, + #[serde(rename = "peerType")] + peer_type: String, + #[serde(rename = "isPostPolicy")] + is_post_policy: bool, + #[serde(rename = "isAdjRibOut")] + is_adj_rib_out: bool, + timestamp: f64, +} + +#[derive(serde::Serialize)] +struct WasmTlv { + #[serde(rename = "type")] + tlv_type: String, + value: String, +} + +// ── Conversion helpers ─────────────────────────────────────────────────────── + +fn make_openbmp_header(header: &crate::parser::bmp::openbmp::OpenBmpHeader) -> WasmOpenBmpHeader { + WasmOpenBmpHeader { + router_ip: header.router_ip.to_string(), + router_group: header.router_group.clone(), + admin_id: header.admin_id.clone(), + timestamp: header.timestamp, + } +} + +fn make_peer_header(pph: &BmpPerPeerHeader) -> WasmPeerHeader { + let (is_post_policy, is_adj_rib_out) = match pph.peer_flags { + PerPeerFlags::PeerFlags(f) => (f.is_post_policy(), f.is_adj_rib_out()), + PerPeerFlags::LocalRibPeerFlags(_) => (false, false), + }; + WasmPeerHeader { + peer_ip: pph.peer_ip.to_string(), + peer_asn: pph.peer_asn.into(), + peer_bgp_id: pph.peer_bgp_id.to_string(), + peer_type: format!("{:?}", pph.peer_type), + is_post_policy, + is_adj_rib_out, + timestamp: pph.timestamp, + } +} + +fn build_bmp_result( + bmp_msg: BmpMessage, + openbmp_header: Option, + timestamp: f64, +) -> WasmBmpMessage { + let base = WasmMessageBase { + openbmp_header, + timestamp, + }; + + match (bmp_msg.per_peer_header, bmp_msg.message_body) { + (Some(pph), BmpMessageBody::RouteMonitoring(m)) => { + let elems = + Elementor::bgp_to_elems(m.bgp_message, timestamp, &pph.peer_ip, &pph.peer_asn); + WasmBmpMessage::RouteMonitoring { + base, + peer_header: make_peer_header(&pph), + elems, + } + } + (Some(pph), BmpMessageBody::PeerUpNotification(m)) => WasmBmpMessage::PeerUpNotification { + base, + peer_header: make_peer_header(&pph), + local_ip: m.local_addr.to_string(), + local_port: m.local_port, + remote_port: m.remote_port, + }, + (Some(pph), BmpMessageBody::PeerDownNotification(m)) => { + WasmBmpMessage::PeerDownNotification { + base, + peer_header: make_peer_header(&pph), + reason: format!("{:?}", m.reason), + } + } + (_, BmpMessageBody::InitiationMessage(m)) => WasmBmpMessage::InitiationMessage { + base, + tlvs: m + .tlvs + .into_iter() + .map(|t| WasmTlv { + tlv_type: format!("{:?}", t.info_type), + value: t.info, + }) + .collect(), + }, + (_, BmpMessageBody::TerminationMessage(m)) => WasmBmpMessage::TerminationMessage { + base, + tlvs: m + .tlvs + .into_iter() + .map(|t| WasmTlv { + tlv_type: format!("{:?}", t.info_type), + value: format!("{:?}", t.info_value), + }) + .collect(), + }, + (Some(pph), BmpMessageBody::StatsReport(_)) => WasmBmpMessage::StatisticsReport { + base, + peer_header: make_peer_header(&pph), + }, + (Some(pph), BmpMessageBody::RouteMirroring(_)) => WasmBmpMessage::RouteMirroringMessage { + base, + peer_header: make_peer_header(&pph), + }, + // Messages without a per-peer header that aren't Initiation/Termination + // should not occur per RFC 7854, but handle gracefully. + (None, BmpMessageBody::StatsReport(_)) => WasmBmpMessage::StatisticsReport { + base, + peer_header: make_peer_header(&BmpPerPeerHeader::default()), + }, + (None, BmpMessageBody::RouteMirroring(_)) => WasmBmpMessage::RouteMirroringMessage { + base, + peer_header: make_peer_header(&BmpPerPeerHeader::default()), + }, + (None, BmpMessageBody::RouteMonitoring(_)) => WasmBmpMessage::RouteMonitoring { + base, + peer_header: make_peer_header(&BmpPerPeerHeader::default()), + elems: vec![], + }, + (None, BmpMessageBody::PeerUpNotification(m)) => WasmBmpMessage::PeerUpNotification { + base, + peer_header: make_peer_header(&BmpPerPeerHeader::default()), + local_ip: m.local_addr.to_string(), + local_port: m.local_port, + remote_port: m.remote_port, + }, + (None, BmpMessageBody::PeerDownNotification(m)) => WasmBmpMessage::PeerDownNotification { + base, + peer_header: make_peer_header(&BmpPerPeerHeader::default()), + reason: format!("{:?}", m.reason), + }, + } +} + +// ── Core parsing functions (testable without wasm_bindgen) ─────────────────── + +/// Parse an OpenBMP message, returning a JSON string or `Ok(None)` for +/// unsupported (non-router) messages. +fn parse_openbmp_message_core(data: &[u8]) -> Result, String> { + let mut bytes = Bytes::from(data.to_vec()); + + let header = match parse_openbmp_header(&mut bytes) { + Ok(h) => h, + Err(ParserBmpError::UnsupportedOpenBmpMessage) => return Ok(None), + Err(e) => return Err(e.to_string()), + }; + + let bmp_msg = parse_bmp_msg(&mut bytes).map_err(|e| e.to_string())?; + + let result = build_bmp_result( + bmp_msg, + Some(make_openbmp_header(&header)), + header.timestamp, + ); + + serde_json::to_string(&result) + .map(Some) + .map_err(|e| e.to_string()) +} + +/// Parse a raw BMP message, returning a JSON string. +fn parse_bmp_message_core(data: &[u8], timestamp: f64) -> Result { + let mut bytes = Bytes::from(data.to_vec()); + + let bmp_msg = parse_bmp_msg(&mut bytes).map_err(|e| e.to_string())?; + + let result = build_bmp_result(bmp_msg, None, timestamp); + + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +/// Result of parsing a single MRT record: the elems + number of bytes consumed. +#[derive(serde::Serialize)] +struct MrtRecordResult { + elems: Vec, + #[serde(rename = "bytesRead")] + bytes_read: u32, +} + +use std::cell::RefCell; + +thread_local! { + /// Persistent Elementor for streaming MRT parsing. Retains the + /// PeerIndexTable across calls so TABLE_DUMP_V2 RIB entries resolve correctly. + static MRT_ELEMENTOR: RefCell = RefCell::new(Elementor::default()); +} + +/// Parse one MRT record from the start of `data`. +/// Returns a JSON `{elems, bytesRead}` string, or empty string when no more records. +/// +/// Uses a thread-local `Elementor` to retain the PeerIndexTable across calls, +/// which is required for TABLE_DUMP_V2 (RIB dump) files. Call +/// `resetMrtParser()` before starting a new file. +fn parse_mrt_record_core(data: &[u8]) -> Result { + if data.is_empty() { + return Ok(String::new()); + } + + let mut cursor = Cursor::new(data); + + let record = match parse_mrt_record(&mut cursor) { + Ok(r) => r, + Err(_) => return Ok(String::new()), // no more valid records + }; + + let bytes_read = cursor.position() as u32; + let elems = MRT_ELEMENTOR.with(|e| e.borrow_mut().record_to_elems(record)); + + let result = MrtRecordResult { elems, bytes_read }; + serde_json::to_string(&result).map_err(|e| e.to_string()) +} + +/// Parse a single BGP UPDATE message, returning a JSON array string. +fn parse_bgp_update_core(data: &[u8]) -> Result { + let mut bytes = Bytes::from(data.to_vec()); + let msg = + parse_bgp_message(&mut bytes, false, &AsnLength::Bits32).map_err(|e| e.to_string())?; + let elems = Elementor::bgp_to_elems( + msg, + 0.0, + &IpAddr::V4(Ipv4Addr::UNSPECIFIED), + &Asn::default(), + ); + serde_json::to_string(&elems).map_err(|e| e.to_string()) +} + +// ── Exported WASM functions (thin wrappers) ────────────────────────────────── + +/// Parse an OpenBMP-wrapped BMP message as received from the RouteViews Kafka stream. +/// +/// Returns a JSON string representing a `BmpParsedMessage` discriminated on `"type"`. +/// Returns an empty string for non-router OpenBMP frames (the JS wrapper converts +/// this to `null`). +/// +/// Throws a JavaScript `Error` on malformed data. +#[wasm_bindgen(js_name = "parseOpenBmpMessage")] +pub fn parse_openbmp_message(data: &[u8]) -> Result { + match parse_openbmp_message_core(data) { + Ok(Some(json)) => Ok(json), + Ok(None) => Ok(String::new()), + Err(e) => Err(JsError::new(&e)), + } +} + +/// Parse a raw BMP message (no OpenBMP wrapper). +/// +/// The `timestamp` parameter provides the collection time in seconds since +/// Unix epoch. +/// +/// Returns a JSON string representing a `BmpParsedMessage`. +/// Throws a JavaScript `Error` on malformed data. +#[wasm_bindgen(js_name = "parseBmpMessage")] +pub fn parse_bmp_message(data: &[u8], timestamp: f64) -> Result { + parse_bmp_message_core(data, timestamp).map_err(|e| JsError::new(&e)) +} + +/// Reset the internal MRT parser state. +/// +/// Call this before parsing a new MRT file with `parseMrtRecord` to clear +/// the PeerIndexTable from a previous file. +#[wasm_bindgen(js_name = "resetMrtParser")] +pub fn reset_mrt_parser() { + MRT_ELEMENTOR.with(|e| *e.borrow_mut() = Elementor::default()); +} + +/// Parse a single MRT record from the start of the given buffer. +/// +/// Returns a JSON object `{ elems: BgpElem[], bytesRead: number }` on success. +/// Returns an empty string when there are no more records (the JS wrapper +/// converts this to `null`). +/// +/// The caller should slice off `bytesRead` bytes from the front of the buffer +/// before the next call. This enables streaming/incremental MRT parsing without +/// passing the full buffer each time. +/// +/// Throws a JavaScript `Error` on malformed data. +#[wasm_bindgen(js_name = "parseMrtRecord")] +pub fn parse_mrt_record_wasm(data: &[u8]) -> Result { + parse_mrt_record_core(data).map_err(|e| JsError::new(&e)) +} + +/// 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 modern default). +/// +/// The returned elements will have a timestamp of `0.0` and unspecified +/// peer IP / peer ASN since those are not part of the BGP message itself — +/// the caller should populate these from external context if needed. +/// +/// Returns a JSON array of `BgpElem` objects. +/// Throws a JavaScript `Error` if the message cannot be parsed. +#[wasm_bindgen(js_name = "parseBgpUpdate")] +pub fn parse_bgp_update(data: &[u8]) -> Result { + parse_bgp_update_core(data).map_err(|e| JsError::new(&e)) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal BMP Initiation message (type 4). + /// BMP common header: version(1) + length(4) + type(1) = 6 bytes + /// Initiation TLV: type(2) + length(2) + value + fn make_bmp_initiation(sys_name: &str) -> Vec { + let tlv_type: u16 = 2; // sysName + let tlv_len = sys_name.len() as u16; + let total_len = 6 + 4 + sys_name.len(); + let mut buf = Vec::with_capacity(total_len); + // BMP common header + buf.push(3); // version + buf.extend_from_slice(&(total_len as u32).to_be_bytes()); // length + buf.push(4); // type = Initiation + // TLV + buf.extend_from_slice(&tlv_type.to_be_bytes()); + buf.extend_from_slice(&tlv_len.to_be_bytes()); + buf.extend_from_slice(sys_name.as_bytes()); + buf + } + + /// Build a BMP Peer Up message with a per-peer header. + fn make_bmp_peer_up() -> Vec { + // Per-peer header: type(1) + flags(1) + rd(8) + addr(16) + asn(4) + bgp_id(4) + ts_s(4) + ts_us(4) = 42 + let peer_header_len = 42; + // Peer Up body: local_addr(16) + local_port(2) + remote_port(2) + sent_open + recv_open + // Minimal BGP OPEN: marker(16) + length(2) + type(1) + version(1) + my_as(2) + hold_time(2) + bgp_id(4) + opt_len(1) = 29 + let bgp_open_len = 29u16; + let body_len = 16 + 2 + 2 + (bgp_open_len as usize) * 2; + let total_len = 6 + peer_header_len + body_len; + let mut buf = Vec::with_capacity(total_len); + + // BMP common header + buf.push(3); // version + buf.extend_from_slice(&(total_len as u32).to_be_bytes()); + buf.push(3); // type = Peer Up Notification + + // Per-peer header + buf.push(0); // peer type = Global + buf.push(0); // peer flags + buf.extend_from_slice(&[0u8; 8]); // route distinguisher + buf.extend_from_slice(&[0u8; 12]); // padding for IPv4 + buf.extend_from_slice(&[10, 0, 0, 1]); // peer IPv4 + buf.extend_from_slice(&65000u32.to_be_bytes()); // peer ASN + buf.extend_from_slice(&[10, 0, 0, 1]); // peer BGP ID + buf.extend_from_slice(&1000u32.to_be_bytes()); // timestamp seconds + buf.extend_from_slice(&0u32.to_be_bytes()); // timestamp microseconds + + // Peer Up body + buf.extend_from_slice(&[0u8; 12]); // padding for IPv4 + buf.extend_from_slice(&[192, 168, 1, 1]); // local IPv4 + buf.extend_from_slice(&179u16.to_be_bytes()); // local port + buf.extend_from_slice(&12345u16.to_be_bytes()); // remote port + + // Sent OPEN message + let open = make_bgp_open(65000, [10, 0, 0, 1]); + buf.extend_from_slice(&open); + // Received OPEN message + buf.extend_from_slice(&open); + + buf + } + + /// Build a minimal BGP OPEN message. + fn make_bgp_open(my_as: u16, bgp_id: [u8; 4]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&[0xFF; 16]); // marker + buf.extend_from_slice(&29u16.to_be_bytes()); // length + buf.push(1); // type = OPEN + buf.push(4); // version + buf.extend_from_slice(&my_as.to_be_bytes()); + buf.extend_from_slice(&180u16.to_be_bytes()); // hold time + buf.extend_from_slice(&bgp_id); + buf.push(0); // opt params length + buf + } + + /// Build a minimal BGP UPDATE with a single IPv4 announcement. + fn make_bgp_update_announce(prefix: [u8; 4], prefix_len: u8, next_hop: [u8; 4]) -> Vec { + // Path attributes: ORIGIN(IGP) + AS_PATH(1 AS_SEQUENCE with AS 65001) + NEXT_HOP + let origin_attr = [0x40, 1, 1, 0]; // flags, type=1, len=1, IGP=0 + #[rustfmt::skip] + let as_path_attr = [ + 0x40, 2, 6, // flags, type=2, len=6 + 2, // segment type = AS_SEQUENCE + 1, // segment length = 1 ASN + 0, 0, 0xFD, 0xE9, // AS 65001 (4-byte) + ]; + let next_hop_attr = [ + 0x40, + 3, + 4, + next_hop[0], + next_hop[1], + next_hop[2], + next_hop[3], + ]; + + let prefix_bytes = (prefix_len as usize).div_ceil(8); + let nlri_len = 1 + prefix_bytes; // prefix_len byte + prefix bytes + let path_attr_len = origin_attr.len() + as_path_attr.len() + next_hop_attr.len(); + let body_len = 2 + 2 + path_attr_len + nlri_len; // withdrawn_len(2) + attr_len(2) + attrs + nlri + let total_len = 19 + body_len; // marker(16) + length(2) + type(1) + body + + let mut buf = Vec::new(); + buf.extend_from_slice(&[0xFF; 16]); // marker + buf.extend_from_slice(&(total_len as u16).to_be_bytes()); + buf.push(2); // type = UPDATE + buf.extend_from_slice(&0u16.to_be_bytes()); // withdrawn routes length + buf.extend_from_slice(&(path_attr_len as u16).to_be_bytes()); + buf.extend_from_slice(&origin_attr); + buf.extend_from_slice(&as_path_attr); + buf.extend_from_slice(&next_hop_attr); + buf.push(prefix_len); + buf.extend_from_slice(&prefix[..prefix_bytes]); + buf + } + + #[test] + fn test_parse_bmp_initiation() { + let data = make_bmp_initiation("test-router"); + let json = parse_bmp_message_core(&data, 1000.0).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["type"], "InitiationMessage"); + assert_eq!(v["timestamp"], 1000.0); + assert!(v["openBmpHeader"].is_null()); + assert_eq!(v["tlvs"][0]["type"], "SysName"); + assert_eq!(v["tlvs"][0]["value"], "test-router"); + } + + #[test] + fn test_parse_bmp_peer_up() { + let data = make_bmp_peer_up(); + let json = parse_bmp_message_core(&data, 1000.0).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["type"], "PeerUpNotification"); + assert_eq!(v["peerHeader"]["peerIp"], "10.0.0.1"); + assert_eq!(v["peerHeader"]["peerAsn"], 65000); + assert_eq!(v["localIp"], "192.168.1.1"); + assert_eq!(v["localPort"], 179); + assert_eq!(v["remotePort"], 12345); + } + + #[test] + fn test_parse_bmp_invalid_data() { + let result = parse_bmp_message_core(&[0, 1, 2], 0.0); + assert!(result.is_err()); + } + + #[test] + fn test_parse_bgp_update() { + let data = make_bgp_update_announce([10, 0, 0, 0], 24, [192, 168, 1, 1]); + let json = parse_bgp_update_core(&data).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(v.is_array()); + let elems = v.as_array().unwrap(); + assert_eq!(elems.len(), 1); + assert_eq!(elems[0]["type"], "ANNOUNCE"); + assert_eq!(elems[0]["prefix"], "10.0.0.0/24"); + assert_eq!(elems[0]["next_hop"], "192.168.1.1"); + } + + #[test] + fn test_parse_bgp_update_invalid() { + let result = parse_bgp_update_core(&[0xFF; 16]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_mrt_record_empty() { + let result = parse_mrt_record_core(&[]).unwrap(); + assert_eq!(result, ""); // no records → empty string + } + + #[test] + fn test_parse_mrt_record_invalid() { + let result = parse_mrt_record_core(&[0, 1, 2]).unwrap(); + assert_eq!(result, ""); // invalid data → empty string + } + + #[test] + fn test_parse_openbmp_unsupported() { + // OpenBMP header with non-router message type (version=3, type != 12) + let mut data = Vec::new(); + data.extend_from_slice(b"OBMP"); // magic + data.extend_from_slice(&1u16.to_be_bytes()); // major version + data.extend_from_slice(&[0; 6]); // header length (set below), msg_len, flags + data.push(100); // object type (not a router message) + // The exact format doesn't matter — we just need parse_openbmp_header + // to return UnsupportedOpenBmpMessage for a non-router type. + let result = parse_openbmp_message_core(&data); + // Either returns None (unsupported) or an error (malformed) — both are fine + match result { + Ok(None) => {} // unsupported, as expected + Err(_) => {} // malformed header is also acceptable + Ok(Some(_)) => panic!("expected None or Err for non-router OpenBMP message"), + } + } + + #[test] + fn test_make_peer_header_default() { + let pph = BmpPerPeerHeader::default(); + let header = make_peer_header(&pph); + assert_eq!(header.peer_asn, 0); + assert_eq!(header.peer_ip, "0.0.0.0"); + assert!(!header.is_post_policy); + assert!(!header.is_adj_rib_out); + } +} diff --git a/src/wasm/README.md b/src/wasm/README.md new file mode 100644 index 0000000..7118faf --- /dev/null +++ b/src/wasm/README.md @@ -0,0 +1,274 @@ +# @bgpkit/parser — WebAssembly Bindings + +> **Experimental**: The WASM bindings are experimental. The API surface, output +> format, and build process may change in future releases. + +This module compiles bgpkit-parser's BGP/BMP/MRT parsing code to WebAssembly +for use in JavaScript and TypeScript environments. + +## Install + +```sh +npm install @bgpkit/parser +``` + +## Use Cases and Examples + +### 1. Real-time BMP stream processing (Node.js) + +Parse OpenBMP messages from the RouteViews Kafka stream. Each Kafka message +is a small binary frame — no memory concerns. + +**Requires Node.js** — Kafka clients need raw TCP sockets, which are not +available in browsers or Cloudflare Workers. + +```js +const { Kafka } = require('kafkajs'); +const { parseOpenBmpMessage } = require('@bgpkit/parser'); + +const kafka = new Kafka({ + brokers: ['stream.routeviews.org:9092'], +}); + +const consumer = kafka.consumer({ groupId: 'my-app' }); +await consumer.connect(); +await consumer.subscribe({ topic: /^routeviews\.amsix\..+\.bmp_raw$/ }); + +await consumer.run({ + eachMessage: async ({ message }) => { + const msg = parseOpenBmpMessage(message.value); + if (!msg) return; // non-router frame (e.g. collector heartbeat) + + switch (msg.type) { + case 'RouteMonitoring': + for (const elem of msg.elems) { + console.log(elem.type, elem.prefix, elem.as_path); + } + break; + case 'PeerUpNotification': + console.log(`Peer up: ${msg.peerHeader.peerIp} AS${msg.peerHeader.peerAsn}`); + break; + case 'PeerDownNotification': + console.log(`Peer down: ${msg.peerHeader.peerIp} (${msg.reason})`); + break; + } + }, +}); +``` + +If you have raw BMP messages without the OpenBMP wrapper (e.g. from your own +BMP collector), use `parseBmpMessage` instead: + +```js +const { parseBmpMessage } = require('@bgpkit/parser'); + +const msg = parseBmpMessage(bmpBytes, Date.now() / 1000); +``` + +### 2. MRT updates file analysis (Node.js) + +Parse MRT updates files from RouteViews or RIPE RIS archives. Updates files +are typically 5–50 MB compressed (20–200 MB decompressed) and fit comfortably +in memory. + +Supports gzip (`.gz`, RIPE RIS) and bzip2 (`.bz2`, RouteViews) compression. +For bz2, install an optional dependency: `npm install seek-bzip`. + +**Using `streamMrtFrom`** (handles fetch + decompression): + +```js +const { streamMrtFrom } = require('@bgpkit/parser'); + +// RIPE RIS (gzip) +for await (const { elems } of streamMrtFrom('https://data.ris.ripe.net/rrc00/2025.01/updates.20250101.0000.gz')) { + for (const elem of elems) { + console.log(elem.type, elem.prefix, elem.as_path); + } +} + +// RouteViews (bzip2 — requires: npm install seek-bzip) +for await (const { elems } of streamMrtFrom('https://archive.routeviews.org/route-views.amsix/bgpdata/2025.01/UPDATES/updates.20250101.0000.bz2')) { + for (const elem of elems) { + console.log(elem.type, elem.prefix, elem.as_path); + } +} +``` + +**Using `parseMrtRecords`** with manual I/O: + +```js +const fs = require('fs'); +const zlib = require('zlib'); +const { parseMrtRecords } = require('@bgpkit/parser'); + +const raw = zlib.gunzipSync(fs.readFileSync('updates.20250101.0000.gz')); + +for (const { elems } of parseMrtRecords(raw)) { + for (const elem of elems) { + if (elem.type === 'ANNOUNCE') { + console.log(elem.prefix, elem.next_hop, elem.as_path); + } + } +} +``` + +### 3. MRT file analysis (browser) + +Parse MRT files dropped or fetched in the browser. Uses the web entry point +which requires calling `init()` before any parsing. + +**Live demo**: [mrt-explorer.labs.bgpkit.com](https://mrt-explorer.labs.bgpkit.com/) + +```js +import { init, parseMrtRecords } from '@bgpkit/parser/web'; + +await init(); + +// Fetch and decompress a gzip-compressed MRT file +const res = await fetch('https://data.ris.ripe.net/rrc00/2025.01/updates.20250101.0000.gz'); +const stream = res.body.pipeThrough(new DecompressionStream('gzip')); +const raw = new Uint8Array(await new Response(stream).arrayBuffer()); + +for (const { elems } of parseMrtRecords(raw)) { + for (const elem of elems) { + console.log(elem.type, elem.prefix, elem.as_path); + } +} +``` + +### 4. Individual BGP UPDATE parsing (all platforms) + +Parse a single BGP UPDATE message extracted from a pcap capture or received +via an API. The message must include the 16-byte marker, 2-byte length, and +1-byte type header. + +```js +const { parseBgpUpdate } = require('@bgpkit/parser'); + +const elems = parseBgpUpdate(bgpMessageBytes); +for (const elem of elems) { + console.log(elem.type, elem.prefix, elem.next_hop, elem.as_path); +} +``` + +## Memory Considerations + +MRT parsing requires the **entire decompressed file** in memory as a +`Uint8Array` before parsing begins. `parseMrtRecords` then iterates +record-by-record, so parsed output stays small — but the raw bytes remain in +memory throughout. + +| File type | Typical decompressed size | Practical? | +|---|---|---| +| MRT updates (5-min) | 20–200 MB | Yes, all platforms | +| MRT updates (15-min) | 50–500 MB | Yes, Node.js; may exceed browser/Worker limits | +| Full RIB dump | 500 MB – 2+ GB | Not recommended — use the native Rust crate | + +BMP and BGP UPDATE messages are small (KB-sized) and have no memory concerns. + +## API Reference + +### Core parsing functions (all platforms) + +| Function | Input | Output | Use case | +|---|---|---|---| +| `parseOpenBmpMessage(data)` | `Uint8Array` | `BmpParsedMessage \| null` | Real-time BMP streams | +| `parseBmpMessage(data, timestamp)` | `Uint8Array`, `number` | `BmpParsedMessage` | Real-time BMP streams | +| `parseBgpUpdate(data)` | `Uint8Array` | `BgpElem[]` | Individual BGP messages | +| `parseMrtRecords(data)` | `Uint8Array` | `Generator` | MRT file analysis | +| `parseMrtRecord(data)` | `Uint8Array` | `MrtRecordResult \| null` | MRT file analysis (low-level) | +| `resetMrtParser()` | — | `void` | Clear state between MRT files | + +### Node.js I/O helpers + +| Function | Input | Output | Description | +|---|---|---|---| +| `streamMrtFrom(pathOrUrl)` | `string` | `AsyncGenerator` | Fetch + decompress + stream-parse | +| `openMrt(pathOrUrl)` | `string` | `Promise` | Fetch + decompress only | + +These use Node.js `fs`, `http`, `https`, and `zlib` modules. They are **not +available** in bundler, browser, or Cloudflare Worker environments. + +### BMP message types + +BMP parsing functions return a discriminated union on the `type` field. All +types include `timestamp` and `openBmpHeader` (present only via +`parseOpenBmpMessage`). + +| `type` | Additional fields | +|---|---| +| `RouteMonitoring` | `peerHeader`, `elems` (array of `BgpElem`) | +| `PeerUpNotification` | `peerHeader`, `localIp`, `localPort`, `remotePort` | +| `PeerDownNotification` | `peerHeader`, `reason` | +| `InitiationMessage` | `tlvs` (array of `{type, value}`) | +| `TerminationMessage` | `tlvs` (array of `{type, value}`) | +| `StatisticsReport` | `peerHeader` | +| `RouteMirroringMessage` | `peerHeader` | + +## Platform Support + +| Platform | Import | Parsing | I/O helpers | Kafka | +|---|---|---|---|---| +| Node.js (CJS) | `require('@bgpkit/parser')` | Yes | Yes | Yes (via kafkajs) | +| Node.js (ESM) | `import from '@bgpkit/parser'` | Yes | No | Yes (via kafkajs) | +| Bundler (webpack, vite) | `import from '@bgpkit/parser'` | Yes | No | No | +| Browser | `import from '@bgpkit/parser/web'` | Yes (after `init()`) | No | No | +| Cloudflare Workers | `import from '@bgpkit/parser/web'` | Yes (after `init()`) | No | No (no TCP sockets) | + +### Web target + +The web target requires calling `init()` before any parsing functions: + +```js +import { init, parseOpenBmpMessage } from '@bgpkit/parser/web'; + +await init(); +const msg = parseOpenBmpMessage(data); +``` + +You can pass a custom URL to the `.wasm` file if the default path doesn't work: + +```js +await init(new URL('./bgpkit_parser_bg.wasm', import.meta.url)); +``` + +### Cloudflare Workers + +Use the `@bgpkit/parser/web` entry point. Workers cannot connect to Kafka +(no raw TCP sockets), so BMP/BGP data must arrive via HTTP requests. + +Workers have a 128 MB memory limit on the free plan (up to 256 MB on paid), +which is sufficient for MRT updates files and individual message parsing, but +not for full RIB dumps. + +## Versioning + +The npm package version tracks the Rust crate's minor version. For Rust crate +version `0.X.Y`, the npm package is published as `0.X.Z` where `Z` increments +independently for JS-specific changes. + +## Building from Source + +### Prerequisites + +- [Rust](https://rustup.rs/) (stable toolchain) +- [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/): + `cargo install wasm-pack` +- The `wasm32-unknown-unknown` target: + `rustup target add wasm32-unknown-unknown` + +### Build + +```sh +# From the repository root — builds all targets (nodejs, bundler, web) +bash src/wasm/build.sh + +# Output is in pkg/ +cd pkg && npm publish +``` + +To build a single target: + +```sh +wasm-pack build --target nodejs --no-default-features --features wasm +``` diff --git a/src/wasm/build.sh b/src/wasm/build.sh new file mode 100755 index 0000000..fb01448 --- /dev/null +++ b/src/wasm/build.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Build bgpkit-parser WASM package for all three targets (nodejs, bundler, web) +# and assemble a single publishable npm package in ./pkg. +# +# Usage: +# cd +# bash src/wasm/build.sh +# +# Prerequisites: +# - wasm-pack: cargo install wasm-pack +# - wasm32-unknown-unknown target: rustup target add wasm32-unknown-unknown +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +WASM_DIR="src/wasm/js" +OUT_DIR="$REPO_ROOT/pkg" + +echo "==> Building nodejs target..." +wasm-pack build --target nodejs --no-default-features --features wasm +mv pkg pkg-nodejs + +echo "==> Building bundler target..." +wasm-pack build --target bundler --no-default-features --features wasm +mv pkg pkg-bundler + +echo "==> Building web target..." +wasm-pack build --target web --no-default-features --features wasm +mv pkg pkg-web + +echo "==> Assembling package..." +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/nodejs" "$OUT_DIR/bundler" "$OUT_DIR/web" + +# nodejs target: main glue + wasm binary +cp pkg-nodejs/bgpkit_parser.js "$OUT_DIR/nodejs/" +cp pkg-nodejs/bgpkit_parser_bg.wasm "$OUT_DIR/nodejs/" +cp pkg-nodejs/bgpkit_parser_bg.wasm.d.ts "$OUT_DIR/nodejs/" 2>/dev/null || true + +# bundler target: main glue + bg bindings + wasm binary +cp pkg-bundler/bgpkit_parser.js "$OUT_DIR/bundler/" +cp pkg-bundler/bgpkit_parser_bg.js "$OUT_DIR/bundler/" +cp pkg-bundler/bgpkit_parser_bg.wasm "$OUT_DIR/bundler/" +cp pkg-bundler/bgpkit_parser_bg.wasm.d.ts "$OUT_DIR/bundler/" 2>/dev/null || true +cp pkg-bundler/bgpkit_parser.d.ts "$OUT_DIR/bundler/" 2>/dev/null || true + +# web target: main glue + wasm binary (no _bg.js for web target) +cp pkg-web/bgpkit_parser.js "$OUT_DIR/web/" +cp pkg-web/bgpkit_parser_bg.wasm "$OUT_DIR/web/" +cp pkg-web/bgpkit_parser_bg.wasm.d.ts "$OUT_DIR/web/" 2>/dev/null || true +cp pkg-web/bgpkit_parser.d.ts "$OUT_DIR/web/" 2>/dev/null || true + +# JS wrappers and types +cp "$WASM_DIR/index.js" "$OUT_DIR/" +cp "$WASM_DIR/index.mjs" "$OUT_DIR/" +cp "$WASM_DIR/index.d.ts" "$OUT_DIR/" +cp "$WASM_DIR/web.mjs" "$OUT_DIR/" +cp "$WASM_DIR/web.d.ts" "$OUT_DIR/" +cp "$WASM_DIR/package.json" "$OUT_DIR/" + +# README for npm (displayed on npmjs.com) +cp "$REPO_ROOT/src/wasm/README.md" "$OUT_DIR/" + +# Cleanup temp directories +rm -rf pkg-nodejs pkg-bundler pkg-web + +echo "==> Done. Package ready in $OUT_DIR/" +echo "" +echo "To publish:" +echo " cd pkg && npm publish" diff --git a/src/wasm/js/index.d.ts b/src/wasm/js/index.d.ts new file mode 100644 index 0000000..5137cfd --- /dev/null +++ b/src/wasm/js/index.d.ts @@ -0,0 +1,201 @@ +// TypeScript type definitions for @bgpkit/parser WebAssembly bindings. +// +// These types describe the JSON output produced by the Rust WASM functions. + +// ── Parsing functions ──────────────────────────────────────────────── + +/** + * Parse an OpenBMP-wrapped BMP message (e.g. from the RouteViews Kafka stream). + * Returns null for non-router OpenBMP frames (collector heartbeats). + */ +export function parseOpenBmpMessage(data: Uint8Array): BmpParsedMessage | null; + +/** + * Parse a raw BMP message (no OpenBMP wrapper). + * @param timestamp Collection time in seconds since Unix epoch. + */ +export function parseBmpMessage( + data: Uint8Array, + timestamp: number +): BmpParsedMessage; + +/** + * Parse a single BGP UPDATE message (with 16-byte marker + 2-byte length + * + type header) into BGP elements. Assumes 4-byte ASN encoding. + */ +export function parseBgpUpdate(data: Uint8Array): BgpElem[]; + +/** + * Parse a single MRT record from the start of a buffer. + * Returns null when there are no more records. + * + * Slice off `bytesRead` bytes before the next call to advance. + */ +export function parseMrtRecord(data: Uint8Array): MrtRecordResult | null; + +/** + * Reset the internal MRT parser state. Call before parsing a new file + * with `parseMrtRecord` to clear the PeerIndexTable from a previous file. + * (Called automatically by `parseMrtRecords`.) + */ +export function resetMrtParser(): void; + +/** + * Generator that yields MRT records one at a time, automatically slicing + * the buffer as it advances. Resets parser state before starting. + */ +export function parseMrtRecords( + data: Uint8Array +): Generator; + +export interface MrtRecordResult { + elems: BgpElem[]; + bytesRead: number; +} + +// ── High-level I/O helpers (Node.js only) ──────────────────────────── + +/** + * Open an MRT file from a local path or URL, automatically decompressing + * based on the file extension (.gz, .bz2). + */ +export function openMrt(pathOrUrl: string): Promise; + +/** + * Async generator that streams MRT records from a local path or URL. + * Handles fetching, decompression, and incremental parsing. + */ +export function streamMrtFrom( + pathOrUrl: string +): AsyncGenerator; + +// ── BMP message types (discriminated union on `type`) ──────────────── + +export type BmpParsedMessage = + | BmpRouteMonitoringMessage + | BmpPeerUpMessage + | BmpPeerDownMessage + | BmpInitiationMessage + | BmpTerminationMessage + | BmpStatsReportMessage + | BmpRouteMirroringMessage; + +interface BmpMessageBase { + openBmpHeader: OpenBmpHeader | null; + timestamp: number; +} + +export interface BmpRouteMonitoringMessage extends BmpMessageBase { + type: "RouteMonitoring"; + peerHeader: BmpPeerHeader; + elems: BgpElem[]; +} + +export interface BmpPeerUpMessage extends BmpMessageBase { + type: "PeerUpNotification"; + peerHeader: BmpPeerHeader; + localIp: string; + localPort: number; + remotePort: number; +} + +export interface BmpPeerDownMessage extends BmpMessageBase { + type: "PeerDownNotification"; + peerHeader: BmpPeerHeader; + reason: string; +} + +export interface BmpInitiationMessage extends BmpMessageBase { + type: "InitiationMessage"; + tlvs: Array<{ type: string; value: string }>; +} + +export interface BmpTerminationMessage extends BmpMessageBase { + type: "TerminationMessage"; + tlvs: Array<{ type: string; value: string }>; +} + +export interface BmpStatsReportMessage extends BmpMessageBase { + type: "StatisticsReport"; + peerHeader: BmpPeerHeader; +} + +export interface BmpRouteMirroringMessage extends BmpMessageBase { + type: "RouteMirroringMessage"; + peerHeader: BmpPeerHeader; +} + +// ── Shared types ───────────────────────────────────────────────────── + +export interface OpenBmpHeader { + routerIp: string; + routerGroup: string | null; + adminId: string; + timestamp: number; +} + +export interface BmpPeerHeader { + peerIp: string; + peerAsn: number; + peerBgpId: string; + peerType: string; + isPostPolicy: boolean; + isAdjRibOut: boolean; + timestamp: number; +} + +export interface BgpElem { + timestamp: number; + type: "ANNOUNCE" | "WITHDRAW"; + peer_ip: string; + peer_asn: number; + peer_bgp_id: string | null; + prefix: string; + next_hop: string | null; + as_path: AsPath | null; + origin_asns: number[] | null; + origin: string | null; + local_pref: number | null; + med: number | null; + communities: MetaCommunity[] | null; + atomic: boolean; + aggr_asn: number | null; + aggr_ip: string | null; + only_to_customer: number | null; +} + +// ── AS path types ──────────────────────────────────────────────────── + +/** + * AS path in simplified format: a flat array where numbers are ASNs in + * AS_SEQUENCE segments and nested arrays are AS_SET members. + * Example: [6447, 39120, [643, 836], 352] + * + * Falls back to verbose format with confederation segments: + * [{ ty: "AS_CONFED_SEQUENCE", values: [123, 942] }, ...] + */ +export type AsPath = AsPathElement[]; +export type AsPathElement = number | number[] | AsPathVerboseSegment; +export interface AsPathVerboseSegment { + ty: "AS_SET" | "AS_SEQUENCE" | "AS_CONFED_SEQUENCE" | "AS_CONFED_SET"; + values: number[]; +} + +// ── Community types ────────────────────────────────────────────────── + +/** Discriminated union of all BGP community types (serde untagged). */ +export type MetaCommunity = PlainCommunity | LargeCommunity | ExtendedCommunity; + +export type PlainCommunity = + | { Custom: [number, number] } + | "NoExport" + | "NoAdvertise" + | "NoExportSubConfed"; + +export interface LargeCommunity { + global_admin: number; + local_data: [number, number]; +} + +/** Extended community — many variants, treat as opaque JSON object. */ +export type ExtendedCommunity = Record; diff --git a/src/wasm/js/index.js b/src/wasm/js/index.js new file mode 100644 index 0000000..6a7230a --- /dev/null +++ b/src/wasm/js/index.js @@ -0,0 +1,322 @@ +'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)); +} + +// ── 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; +} + +/** + * 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} + */ +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} + */ +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} 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, + parseMrtRecords, + parseMrtRecord, + resetMrtParser, + + // Node.js I/O helpers + openMrt, + streamMrtFrom, +}; diff --git a/src/wasm/js/index.mjs b/src/wasm/js/index.mjs new file mode 100644 index 0000000..cfba09b --- /dev/null +++ b/src/wasm/js/index.mjs @@ -0,0 +1,59 @@ +import * as wasm from './bundler/bgpkit_parser.js'; + +export function parseOpenBmpMessage(data) { + const json = wasm.parseOpenBmpMessage(data); + return json ? JSON.parse(json) : null; +} + +export function parseBmpMessage(data, timestamp) { + return JSON.parse(wasm.parseBmpMessage(data, timestamp)); +} + +export function parseBgpUpdate(data) { + return JSON.parse(wasm.parseBgpUpdate(data)); +} + +// ── Streaming MRT record parsing ───────────────────────────────────── + +const MRT_HEADER_LEN = 12; + +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); +} + +export 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; +} + +export function resetMrtParser() { + wasm.resetMrtParser(); +} + +export 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; + } +} diff --git a/src/wasm/js/package.json b/src/wasm/js/package.json new file mode 100644 index 0000000..54618bb --- /dev/null +++ b/src/wasm/js/package.json @@ -0,0 +1,58 @@ +{ + "name": "@bgpkit/parser", + "version": "0.15.2", + "description": "BGP/BMP/MRT message parser compiled to WebAssembly", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bgpkit/bgpkit-parser" + }, + "main": "./index.js", + "module": "./index.mjs", + "types": "./index.d.ts", + "exports": { + ".": { + "node": { + "require": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "import": { + "types": "./index.d.ts", + "default": "./index.mjs" + } + }, + "default": { + "types": "./index.d.ts", + "default": "./index.mjs" + } + }, + "./web": { + "types": "./web.d.ts", + "default": "./web.mjs" + } + }, + "files": [ + "nodejs/", + "bundler/", + "web/", + "index.js", + "index.mjs", + "index.d.ts", + "web.mjs", + "web.d.ts", + "README.md" + ], + "keywords": [ + "bgp", + "bmp", + "mrt", + "wasm", + "parser", + "routeviews", + "bgpkit" + ], + "engines": { + "node": ">=18" + } +} diff --git a/src/wasm/js/web.d.ts b/src/wasm/js/web.d.ts new file mode 100644 index 0000000..5489252 --- /dev/null +++ b/src/wasm/js/web.d.ts @@ -0,0 +1,35 @@ +// TypeScript type definitions for the web target entry point. +// The web target requires calling init() before using any parsing functions. + +export { + BmpParsedMessage, + BmpRouteMonitoringMessage, + BmpPeerUpMessage, + BmpPeerDownMessage, + BmpInitiationMessage, + BmpTerminationMessage, + BmpStatsReportMessage, + BmpRouteMirroringMessage, + OpenBmpHeader, + BmpPeerHeader, + BgpElem, + MrtRecordResult, +} from './index'; + +export { + parseOpenBmpMessage, + parseBmpMessage, + parseBgpUpdate, + parseMrtRecord, + resetMrtParser, + parseMrtRecords, +} from './index'; + +/** + * Initialize the WASM module. Must be called (and awaited) before using + * any parsing functions. + * + * @param input - Optional URL, Request, or BufferSource of the .wasm file. + * If omitted, the default path is used. + */ +export function init(input?: RequestInfo | URL | BufferSource): Promise; diff --git a/src/wasm/js/web.mjs b/src/wasm/js/web.mjs new file mode 100644 index 0000000..e832df9 --- /dev/null +++ b/src/wasm/js/web.mjs @@ -0,0 +1,71 @@ +import initWasm from './web/bgpkit_parser.js'; +import * as wasm from './web/bgpkit_parser.js'; + +/** + * Initialize the WASM module. Must be called (and awaited) before using + * any parsing functions. + * + * @param {RequestInfo | URL | BufferSource} [input] - Optional URL or buffer + * of the .wasm file. If omitted, the default path is used. + */ +export async function init(input) { + await initWasm(input); +} + +export function parseOpenBmpMessage(data) { + const json = wasm.parseOpenBmpMessage(data); + return json ? JSON.parse(json) : null; +} + +export function parseBmpMessage(data, timestamp) { + return JSON.parse(wasm.parseBmpMessage(data, timestamp)); +} + +export function parseBgpUpdate(data) { + return JSON.parse(wasm.parseBgpUpdate(data)); +} + +// ── Streaming MRT record parsing ───────────────────────────────────── + +const MRT_HEADER_LEN = 12; + +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); +} + +export 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; +} + +export function resetMrtParser() { + wasm.resetMrtParser(); +} + +export 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; + } +} diff --git a/src/wasm/test/.gitignore b/src/wasm/test/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/src/wasm/test/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/src/wasm/test/package.json b/src/wasm/test/package.json new file mode 100644 index 0000000..7fefdc6 --- /dev/null +++ b/src/wasm/test/package.json @@ -0,0 +1,11 @@ +{ + "name": "bgpkit-parser-wasm-test", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "node test-published.js" + }, + "dependencies": { + "@bgpkit/parser": "0.15.0" + } +} diff --git a/src/wasm/test/test-published.js b/src/wasm/test/test-published.js new file mode 100644 index 0000000..e88b52a --- /dev/null +++ b/src/wasm/test/test-published.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Test script for the published @bgpkit/parser npm package. + * + * Usage: + * cd src/wasm/test + * npm install @bgpkit/parser + * node test-published.js + */ + +const https = require('https'); +const { parseOpenBmpMessage, parseBmpMessage, parseBgpUpdate, parseMrtRecords } = require('@bgpkit/parser'); + +let passed = 0; +let failed = 0; + +function assert(condition, msg) { + if (condition) { + console.log(` ✓ ${msg}`); + passed++; + } else { + console.error(` ✗ ${msg}`); + failed++; + } +} + +// ── Test 1: parseOpenBmpMessage (PeerDownNotification) ─────────────────────── + +function testOpenBmpPeerDown() { + console.log('\n[1/5] parseOpenBmpMessage (PeerDownNotification)'); + + // Complete OpenBMP PeerDownNotification from the bgpkit-parser test suite + const hex = + '4f424d500107006400000033800c6184b9c2000c602cbf4f072f3ae149d23486024bc3dadfc4000a' + + '69732d63632d626d7031c677060bdd020a9e92be000200de2e3180df3369000000000000000000000' + + '000000c726f7574652d76696577733500000001030000003302000000000000000000000000000000' + + '000000000000003fda060e00000da30000000061523c36000c0e1c0200000a'; + + const data = Buffer.from(hex, 'hex'); + const msg = parseOpenBmpMessage(data); + + assert(msg !== null, 'returns non-null for valid message'); + assert(msg.type === 'PeerDownNotification', `type is PeerDownNotification (got ${msg.type})`); + assert(msg.openBmpHeader !== null, 'openBmpHeader is present'); + assert(typeof msg.openBmpHeader.routerIp === 'string', `routerIp: ${msg.openBmpHeader.routerIp}`); + assert(msg.peerHeader !== undefined, 'peerHeader is present'); + assert(typeof msg.peerHeader.peerAsn === 'number', `peerAsn: ${msg.peerHeader.peerAsn}`); + assert(typeof msg.reason === 'string', `reason: ${msg.reason}`); + assert(typeof msg.timestamp === 'number', `timestamp: ${msg.timestamp}`); +} + +// ── Test 2: parseOpenBmpMessage — null for non-router frames ───────────────── + +function testOpenBmpNull() { + console.log('\n[2/5] parseOpenBmpMessage (non-router frame)'); + + // OpenBMP header with object_type = 0x00 (not 0x0C = router message) + const hex = + '4f424d500100005c000000b0800c618881530002f643fef880938d19e9d632c815d1e95a87e1000a' + + '69732d61682d626d7031eb4de4e596b282c6a995b067df4abc8cc342f192'; + const data = Buffer.from(hex, 'hex'); + + let result; + try { + result = parseOpenBmpMessage(data); + } catch { + result = 'threw'; + } + assert(result === null, 'returns null for non-router OpenBMP frame'); +} + +// ── Test 3: parseBmpMessage ────────────────────────────────────────────────── + +function testBmpMessage() { + console.log('\n[3/5] parseBmpMessage'); + + // Extract the BMP portion from the PeerDown test (skip the OpenBMP header). + // The OpenBMP header is the first 100 bytes (0x64 hex in the length field). + // BMP message starts after the OpenBMP header. + const fullHex = + '4f424d500107006400000033800c6184b9c2000c602cbf4f072f3ae149d23486024bc3dadfc4000a' + + '69732d63632d626d7031c677060bdd020a9e92be000200de2e3180df3369000000000000000000000' + + '000000c726f7574652d76696577733500000001030000003302000000000000000000000000000000' + + '000000000000003fda060e00000da30000000061523c36000c0e1c0200000a'; + + // OpenBMP header: magic(4) + ver(2) + hdr_len(2) + msg_len(4) + flags(1) + + // obj_type(1) + timestamp_sec(4) + timestamp_usec(4) + collector_hash(16) + + // admin_id_len(2) + admin_id(variable) + router_hash(16) + router_ip(4/16) + + // router_group_len(2) + router_group(variable) + row_count(4) + // The msg_len field (bytes 6-9) tells us BMP message length: 0x00000033 = 51 bytes + // The hdr_len field (bytes 4-5) tells us OpenBMP header length: 0x0064 = 100 bytes + const fullData = Buffer.from(fullHex, 'hex'); + const hdrLen = fullData.readUInt16BE(6); + const bmpData = fullData.subarray(hdrLen); + const now = Date.now() / 1000; + + const msg = parseBmpMessage(bmpData, now); + + assert(msg !== null, 'returns non-null'); + assert(msg.type === 'PeerDownNotification', `type is PeerDownNotification (got ${msg.type})`); + assert(msg.openBmpHeader === null, 'openBmpHeader is null (no OpenBMP wrapper)'); + assert(typeof msg.timestamp === 'number', 'timestamp is a number'); +} + +// ── Test 4: parseBgpUpdate ─────────────────────────────────────────────────── + +function testBgpUpdate() { + console.log('\n[4/5] parseBgpUpdate'); + + // Minimal BGP UPDATE announcing 198.51.100.0/24 via next-hop 192.0.2.1 + // 16-byte marker + 2-byte length + 1-byte type(2=UPDATE) + // + withdrawn_len(0) + path_attr_len + attrs + NLRI + const hex = + 'ffffffffffffffffffffffffffffffff' + // marker + '00380200' + // length=56, type=UPDATE + '0000' + // withdrawn routes length = 0 + '001b' + // total path attr length = 27 + '40010100' + // ORIGIN: IGP + '40020a0202000000fd00000065' + // AS_PATH: 253 101 + '400304c0000201' + // NEXT_HOP: 192.0.2.1 + '18c63364'; // NLRI: 198.51.100.0/24 + + const data = Buffer.from(hex, 'hex'); + const elems = parseBgpUpdate(data); + + assert(Array.isArray(elems), 'returns an array'); + assert(elems.length === 1, `has ${elems.length} element(s)`); + + const elem = elems[0]; + assert(elem.type === 'ANNOUNCE', `type is ANNOUNCE (got ${elem.type})`); + assert(elem.prefix === '198.51.100.0/24', `prefix is ${elem.prefix}`); + assert(elem.next_hop === '192.0.2.1', `next_hop is ${elem.next_hop}`); +} + +// ── Test 5: parseMrtRecords (streaming) ────────────────────────────────────── + +function testMrtRecords() { + return new Promise((resolve) => { + console.log('\n[5/5] parseMrtRecords'); + console.log(' ↓ downloading test MRT file...'); + + const url = 'https://spaces.bgpkit.org/parser/update-example'; + + https.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + https.get(res.headers.location, (res2) => handleResponse(res2, resolve)); + return; + } + handleResponse(res, resolve); + }).on('error', (err) => { + console.log(` ⚠ skipped (download failed: ${err.message})`); + resolve(); + }); + }); +} + +function handleResponse(res, resolve) { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks); + console.log(` ↓ downloaded ${(raw.length / 1024).toFixed(0)} KB`); + + const allElems = []; + for (const { elems } of parseMrtRecords(raw)) { + allElems.push(...elems); + } + + assert(allElems.length > 0, `parsed ${allElems.length} elements`); + + const announce = allElems.find((e) => e.type === 'ANNOUNCE'); + if (announce) { + assert(typeof announce.prefix === 'string', `sample prefix: ${announce.prefix}`); + assert(typeof announce.peer_ip === 'string', `sample peer_ip: ${announce.peer_ip}`); + assert(typeof announce.peer_asn === 'number', `sample peer_asn: ${announce.peer_asn}`); + } + + resolve(); + }); + res.on('error', (err) => { + console.log(` ⚠ skipped (download failed: ${err.message})`); + resolve(); + }); +} + +// ── Run ────────────────────────────────────────────────────────────────────── + +async function main() { + console.log('Testing @bgpkit/parser npm package\n'); + + testOpenBmpPeerDown(); + testOpenBmpNull(); + testBmpMessage(); + testBgpUpdate(); + await testMrtRecords(); + + console.log(`\n${passed + failed} assertions: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +main();