rdfjs-jelly
v0.1.4
Published
Jelly-RDF parser and writer for RDF/JS in Node.js and browsers
Maintainers
Readme
rdfjs-jelly
Jelly-RDF binary parser and writer for RDF/JS, for Node.js and browsers.
Support
- Reads Jelly protocol versions 1 and 2.
- Writes Jelly protocol version 2 only.
- Supports physical triple, quad, and graph streams; delimited and non-delimited encoding; lookup/repeated-term compression; namespaces; and message metadata.
- Uses RDF/JS quads and accepts a custom RDF/JS data factory.
- Does not support RDF-star, generalized RDF, Jelly-Patch, or gRPC.
Node.js 24 or newer is required by the Node build. The browser bundle targets ES2020.
Install
npm install rdfjs-jellyCommand line
Use the package without installing it to inspect a Jelly stream or convert an RDF 1.2 Message Stream Log:
npx rdfjs-jelly inspect 'https://example.org/data.jelly.zst'
npx rdfjs-jelly convert messages.log --output messages.jelly.zstBoth commands accept a URL, local file path, or - for stdin. Plain, gzip, and
zstd-compressed input is detected from its bytes. read is an alias for
inspect; run npx rdfjs-jelly --help for the complete usage. Conversion is a
bounded-memory pipeline: the input, RDF Message parser, Jelly writer, native
zstd compressor, and output file are all streamed.
Parse and write
import { DataFactory, Parser, Writer } from 'rdfjs-jelly';
const { literal, namedNode, quad } = DataFactory;
const writer = new Writer({ namespaces: { ex: 'https://example.org/' } });
writer.addQuad(quad(
namedNode('https://example.org/s'),
namedNode('https://example.org/p'),
literal('hello', 'en'),
));
writer.end((error, bytes) => {
if (error) throw error;
const quads = new Parser().parse(bytes!);
console.log(quads);
});Writer emits a delimited version-2 stream by default. Set delimited: false for a single-message protobuf payload. A non-delimited output cannot contain multiple messages.
Messages
One Jelly RdfStreamFrame is exposed as one Message. Message extends Array<RDF.Quad> and carries messageCounter and binary metadata.
const writer = new Writer();
writer.addMessage([quad1], { source: new TextEncoder().encode('sensor-a') });
writer.addMessage([]); // Empty messages are preserved.
writer.addMessage([quad2]);
writer.end((error, bytes) => {
if (error) throw error;
const messages = new Parser().parseMessages(bytes!);
console.log(messages.map(message => message.messageCounter));
});For compatibility with rdf-parser-ts, use new Parser({ messages: true }) to receive { quad, messageCounter } entries. The returned array has a messageCount property. isMessageQuad() and toMessages() convert between flat and grouped forms while preserving empty messages.
Node streams
import { createReadStream } from 'node:fs';
import { StreamParser } from 'rdfjs-jelly';
for await (const quad of createReadStream('data.jelly').pipe(new StreamParser())) {
console.log(quad);
}StreamWriter accepts RDF/JS quads in object mode and emits binary chunks. Both Node stream classes expose import(readable).
Browser streams
import { StreamParser } from 'rdfjs-jelly/browser';
const response = await fetch('/data.jelly');
for await (const quad of response.body!.pipeThrough(new StreamParser())) {
console.log(quad);
}The browser parser accepts Uint8Array and ArrayBuffer chunks. Browser and Node parsers emit options, namespace, message, and messageCounter events.
Browser playground
Build the browser bundle, serve the repository, and open /index.html:
npm run build:browser
python3 -m http.server 8000The bundled example/osm-dk-10k.jelly.gz dataset is selected by default. The
playground accepts URLs and uploaded files, and detects plain, gzip, and zstd
input from its magic bytes rather than the filename. It steps through one Jelly
RDF Message at a time, can fast-forward to the end, and retains a rolling
history of 20 messages.
The conversion mode parses an RDF 1.2 Message Stream Log with rdf-parser-ts,
preserves empty message boundaries, writes Jelly protocol version 2, compresses
the result as a standard zstd frame, and saves a .jelly.zst file. Conversion
streams through the parser, Jelly encoder, and zstd WASM codec into a file chosen
with the File System Access API. Browsers without that API cannot provide a
bounded-memory download and should use the CLI. Browser zstd input is streamed
when the browser exposes a native zstd DecompressionStream; otherwise the
WASM fallback still requires a complete compressed input buffer. Gzip and
uncompressed input remain streaming.
Compression
Transport compression is intentionally separate from Jelly framing. Node.js
22.15/23.8 and newer expose createZstdCompress() and
createZstdDecompress() in node:zlib, alongside gzip and Brotli transforms:
import { createReadStream } from 'node:fs';
import { createZstdDecompress } from 'node:zlib';
import { StreamParser } from 'rdfjs-jelly';
const quads = createReadStream('data.jelly.zst')
.pipe(createZstdDecompress())
.pipe(new StreamParser());Browser CompressionStream and DecompressionStream do not currently expose
zstd. The playground therefore uses @hpcc-js/wasm-zstd for incremental zstd
output compression and buffered zstd input decompression, while retaining the
native browser stream API for gzip. The WASM codec is isolated in
dist/browser/playground.mjs, so it does not increase the normal
dist/browser/index.mjs parser bundle.
Performance
These are local microbenchmarks, not universal rankings. They measure 100,000 generated RDF triples with unique subjects and literals and one repeated predicate. Each result is the median of 15 measured runs after two warm-up runs, with case order alternated between rounds. Parsing includes constructing the result RDF objects. Writing includes constructing the writer, adding all quads, and final serialization, starting with already constructed objects. Compressed parsing includes synchronous decompression and uses Node.js's default gzip and Brotli settings; compression itself is performed before the timed section.
Snapshot recorded on 2026-06-29 with Node.js 25.9.0 and Python 3.12 on Linux, using an Intel Core i7-1265U and 30 GiB RAM.
Compared with rdf-parser.ts
This compares equivalent RDF data, but not identical input formats:
rdfjs-jelly parses Jelly while rdf-parser.ts 0.2.6 (ce8846b) parses
N-Triples. It therefore reflects the end-user format choice as well as parser
implementation performance.
Parsing
| Parser | Format | Input size | Median | Throughput | | --- | --- | ---: | ---: | ---: | | rdfjs-jelly | Jelly | 2,579,568 B | 42.9 ms | 2.33 M statements/s | | rdf-parser.ts | N-Triples | 6,377,780 B | 46.6 ms | 2.15 M statements/s | | rdfjs-jelly | Jelly + gzip | 496,581 B | 45.7 ms | 2.19 M statements/s | | rdf-parser.ts | N-Triples + gzip | 490,694 B | 47.0 ms | 2.13 M statements/s | | rdfjs-jelly | Jelly + Brotli | 128,277 B | 44.6 ms | 2.24 M statements/s | | rdf-parser.ts | N-Triples + Brotli | 117,438 B | 46.9 ms | 2.13 M statements/s |
Parsing performance is within about 9% for all three transport variants on this dataset. Uncompressed Jelly was about 60% smaller (2.47 times less data). The highly repetitive N-Triples input was about 1.2% smaller after gzip and 8.4% smaller after Brotli. Compressed sizes are dataset-dependent and should not be extrapolated to less repetitive RDF.
Writing
| Writer | Format | Output size | Median | Throughput | | --- | --- | ---: | ---: | ---: | | rdfjs-jelly | Jelly | 2,579,568 B | 126.6 ms | 0.79 M statements/s | | rdf-parser.ts | N-Triples | 6,377,780 B | 35.3 ms | 2.83 M statements/s |
For this workload, rdf-parser.ts wrote N-Triples about 3.6 times faster. Jelly
writing performs lookup-table management, frame construction, and protobuf
serialization, producing an output about 60% smaller before transport compression.
Profiling and optimization
The initial V8 profile spent about 337 ms of a 376 ms parse in protobuf framing and decoding. Its hottest functions were Protobuf-ES descriptor lookup and reflective message construction, followed by garbage collection. RDF/JS materialization itself took about 38 ms.
The optimized path uses schema-specific static protobuf.js code with monomorphic
decoder call sites. Common triple, IRI, literal, and lookup-entry payloads are
decoded directly from protobuf bytes into the RDF/JS factory, avoiding temporary
protobuf objects and frame/row object graphs. Plain Parser.parse() also writes
quads directly to its result array instead of constructing intermediate
Message arrays, and Node inputs are normalized to zero-copy Buffer views.
Immutable datatype terms are cached by their bounded Jelly lookup IDs. A generic string-keyed NamedNode cache was tested but rejected: on this unique-subject workload it increased parse time because Jelly's repeated-term encoding already handles the common reuse case. Together with straight-line statement validation, the retained changes reduced the complete parse from about 359 ms at the measured pre-optimization baseline to roughly 37–43 ms, or about 9 times faster.
The writer profile originally spent about 137 ms maintaining lookup tables and
constructing frame rows, plus about 72 ms converting and serializing protobuf
objects. Lookup eviction now uses indexed O(1) LRU links instead of repeatedly
creating Map iterators, lookup insertion and reference selection share one map
probe, and statement encoding reuses scratch storage with straight-line term
comparisons. Frames are serialized directly from Jelly rows without first
creating a second protobuf object graph. In the comparison above these changes
reduced writing from 200.6 ms to 126.6 ms, about 1.6 times faster.
Reproduce the phase breakdown with:
npm run perf:profile -- 100000 7
npm run perf:profile:writer -- 100000 7Reproduce it from this repository, with ../rdf-parser.ts built:
npm run perf:compare:rdf-parser -- 100000 15Set RDF_PARSER_TS_PATH to compare against a different build.
Development
npm install
npm run lint
npm test
npm run build
npm run check
npm run proto:generateThe checked-in schema is Jelly-RDF rdf.proto 1.1.1. Static JavaScript codecs
and TypeScript declarations are generated with protobuf.js. The generation
step replaces generic oneof setter calls with cheap discriminator markers;
src/generated/rdf_pb.ts consumes those markers while preserving protobuf's
last-one-wins semantics.
Tests include pinned official Jelly conformance fixtures and pyjelly-compatible version-2 writer behavior.
License
Apache 2
