n3.js-messages
v0.1.4
Published
RDF Messages (message-delimited Turtle, TriG, N-Triples and N-Quads) for N3.js: message-aware parser, streaming parser and writer.
Maintainers
Readme
n3.js-messages
RDF Messages for N3.js:
parse, stream and write message-delimited Turtle, TriG, N-Triples and N-Quads, with ordinary RDF/JS Quad objects.
What this package is
An RDF Message Log is an RDF document in which MESSAGE delimiters group the triples or quads into messages, so that a
stream of RDF can be written to a file (or a socket) and read again as the same stream of messages.
This package adds that to N3.js as an extension: it depends on the public API of the n3 package (a peer dependency), and
does not fork, patch or copy N3.js. The syntax of the statements is still parsed and written by N3.js.
Status
RDF Messages is a Living Document of the W3C RDF Stream Processing Community Group;
it is not a W3C Recommendation, and may still change. This package implements the specification of 25 September 2026
(upstream commit 9c3b947) and is versioned 0.x accordingly.
Installation
npm install n3 n3.js-messagesRequires Node.js ≥ 18 and n3 ≥ 2.13.7 < 3. The package works with import and require, and ships TypeScript declarations.
Parsing
import { parseMessages } from 'n3.js-messages';
const messages = parseMessages(`
VERSION "1.2-messages"
PREFIX ex: <http://example.org/>
ex:alice ex:says "Hello" .
MESSAGE
ex:bob ex:says "Hi" .
MESSAGE
MESSAGE
ex:carol ex:says "Bye" .
`, { format: 'Turtle' });
// An array of messages; a message is an array of RDF/JS quads, in document order
console.log(messages.length); // 4 (the third message is empty)
console.log(messages[1][0].object); // Literal "Hi"format is 'Turtle', 'TriG' (the default), 'N-Triples' or 'N-Quads' (or the matching media type).
Other options: baseIRI, factory (any RDF/JS DataFactory), blankNodePrefix, and version / messages: true to announce
a message log without a VERSION line (for example when the media type says so).
MessageParser offers callbacks instead of an array:
import { MessageParser } from 'n3.js-messages';
new MessageParser({ format: 'TriG' }).parse(text, {
onMessage: (error, message, prefixes, index) => { /* message === null at the end */ },
onQuad: (error, quad, messageIndex) => { /* optional */ },
onPrefix: (prefix, iri) => {},
onVersion: label => {},
});Messages are not merged, empty messages are preserved, and quads keep the order of the document.
A document that does not announce a -messages version is one message, and a delimiter in it is an error.
Streaming
MessageStreamParser is a Node.js stream: text (or bytes) in, one array of quads per message out, as soon as the message is
complete, with normal backpressure. It never buffers the input, and keeps nothing of messages that it already emitted.
import { createReadStream } from 'node:fs';
import { MessageStreamParser } from 'n3.js-messages';
const messages = createReadStream('log.trig').pipe(new MessageStreamParser({ format: 'TriG' }));
for await (const message of messages)
console.log(`message with ${message.length} quads`);
// parser.on('prefix', (prefix, iri) => …) and parser.on('version', label => …) are emitted tooChunk boundaries may be anywhere (inside MESSAGE, inside an IRI, between the bytes of a character, after the CR of a CRLF);
the result is identical to parsing the whole string at once.
Writing
import { MessageWriter, MessageStreamWriter, writeMessages } from 'n3.js-messages';
import { DataFactory } from 'n3';
const { namedNode, literal, quad } = DataFactory;
const writer = new MessageWriter(process.stdout, { format: 'TriG', prefixes: { ex: 'http://example.org/' } });
await writer.addMessage([quad(namedNode('http://example.org/alice'), namedNode('http://example.org/says'), literal('Hello'))]);
await writer.addMessage([]); // empty messages are written, too
await writer.end();
const text = writeMessages(messages, { format: 'N-Quads' }); // synchronous, returns a string- Writes
VERSION "1.2-messages"(@version "1.2-messages".in Turtle/TriG) first, and a delimiter after each message:MESSAGEin N-Triples and N-Quads,@message .in Turtle and TriG. A parser gets a message when it is written, and a trailing empty message survives a round trip. - Each message starts from a clean serializer state, so consecutive messages in the same named graph are valid. Prefixes (and
baseIRIwithwriteBase: true) are declared once;addPrefixdeclares more before the next message. - Quads can also be streamed within a message, with constant memory:
beginMessage()(optional),addQuad(quad)(written immediately),endMessage()(writes the delimiter and resets the serializer state; without an open message it writes an empty one).end()closes an open message. Alternatively, tag quads as in rdf-writer-ts,addQuad(quad, messageIndex): a change of index ends the current message and writes the empty messages in between; a lower index is an error.addMessage(quads)is shorthand for begin, add all, end, and fails if a message is open. addMessage,addQuad,beginMessage,endMessageandendaccept a callback or return a promise. They complete when the output stream has accepted the data, not when it was queued.MessageStreamWriteris theTransformequivalent: messages in (object mode), text out. It also accepts{ quad, messageIndex }objects.
Blank node semantics
Blank node labels are scoped to their message: _:a in message 1 and _:a in message 2 are different blank nodes, also when the
whole log is parsed by one parser instance, from one stream, with any data factory. Within one message a label is one node.
Consequently, a blank node that the same quad objects share between two messages is written with the same label twice, and
comes back as two nodes. Prefixes and the base IRI, on the other hand, stay in effect across messages (and a later PREFIX overrides).
Supported syntaxes
| Syntax | Version announcement | Delimiter | Graphs |
| --- | --- | --- | --- |
| Turtle | VERSION "1.2-messages" or @version "1.2-messages" . | MESSAGE or @message . | – |
| TriG | idem | MESSAGE or @message . | graph blocks |
| N-Triples | VERSION "1.2-messages" | MESSAGE (own line) | – |
| N-Quads | VERSION "1.2-messages" | MESSAGE (own line) | fourth term |
The labels 1.2-messages, 1.2-basic-messages and 1.1-messages are accepted. Not supported: NDJSON-LD, YAML-LD, RDF/XML message logs,
Jelly, Notation3. Syntax details and ambiguities of the specification are in IMPLEMENTATION-NOTES.md.
Specification compliance
The RDF Messages test suite is vendored at a pinned upstream revision and run by
npm run test:spec (see spec/README.md). The suite is a prose document, so the cases are extracted from it
mechanically; each parsing case runs through the synchronous parser and through the stream parser with one character per chunk.
Result of the run for this version (node 25.9.0, n3 2.10.6): 16 passed, 0 failed, 12 skipped of 28 cases.
The 12 skipped cases do not apply to RDF syntax parsers and serializers: 7 test protocol adapters (SSE, WebSocket, Kafka, MQTT, other brokers,
Jelly gRPC), and 5 are about NDJSON-LD, a different serialization. The run prints each skipped case with its reason.
npm run test:spec:earl writes an EARL report.
Beyond the suite there are 312 unit, regression, property and differential tests (100% statement, branch, function and line coverage), including every possible two-chunk split of the fixtures, deterministic randomized chunking, per-byte streaming, and the regressions of the review of N3.js pull request 586, which proposed this feature for N3.js itself.
Relationship to N3.js
n3is a peer dependency (>=2.13.7 <3), so that an application has one N3.js installation. It is tested against n3 2.13.7 (minimum) and the latest 2.x, and against N3.jsmainin a scheduled, non-blocking job.- This is not a fork. The N3.js maintainers asked for RDF Messages to live in a separate package until the specification matures (they proposed it in the discussion of pull request 586, which first added the feature to N3.js itself); this package is that package.
- N3.js parses the delimiters itself: the parser is created with
directives: ['message'], and reportsMESSAGEand@message .throughonDirective, in order with the quads. The writer is a small subclass ofN3.Writerthat announces the log with theversionoption and ends every message with_endStatement()before it writes the delimiter. Blank node scoping per message is done by this package.
Performance
npm run perf runs the benchmarks (PERF_QUICK=1 for a smoke run) and writes .perf-results/latest.json; npm run perf:compare checks
ratio-based guardrails against each other and compares with perf/baseline.json. Method, thresholds and their rationale are in
perf/README.md. Measured on an Intel i7-1265U, node 25.9.0 (median of 5; quads/s):
| | plain N3.js | n3.js-messages |
| --- | --- | --- |
| parse 100k quads, one message | 649k | 691k (parseMessages), 735k (stream, 64 KiB chunks) |
| parse 10k messages × 5 quads | – | 564k (parseMessages), 655k (stream) |
| stream, 16-byte chunks (10k quads) | – | 324k |
| write 100k quads in one message (N-Quads) | – | 1.34M |
| write 10k messages (N-Quads) | – | 1.10M (221k messages/s) |
Streaming 100k messages (17 MB) through MessageStreamParser grows the heap by 0.12 MB (after gc).
Comparison with rdf-parser-ts and rdf-writer-ts
rdf-parser-ts and its sibling rdf-writer-ts
also support RDF Messages, as standalone packages instead of a layer over N3.js. The benchmark suite runs the same generated documents
through both (npm run perf, then npm run perf:compare). Throughput in quads per second, median of 5, Intel i7-1265U, Node.js 25.9
(the higher number is faster; a speed above 1× means n3.js-messages is faster):
| Workload | n3.js-messages | rdf-parser-ts / rdf-writer-ts | Speed of n3.js-messages | | --- | --- | --- | --- | | Parse a string, one message, 100k quads | 691k | 551k | 1.25× | | Parse a string, 10k messages × 5 quads | 564k | 546k | 1.03× | | Parse a string, 10 messages × 10k quads | 732k | 596k | 1.23× | | Stream, 64 KiB chunks, 100k quads | 735k | 638k | 1.15× | | Stream, 64 KiB chunks, 10k messages | 655k | 511k | 1.28× | | Stream, 16-byte chunks, 10k quads | 324k | 198k | 1.64× | | Stream, 1-character chunks, 1k quads | 79k | 61k | 1.29× | | Write one message, 100k quads, N-Quads | 1,342k | 1,352k | 0.99× | | Write one message, 100k quads, TriG | 1,079k | 1,281k | 0.84× | | Write 10k messages, N-Quads | 1,103k | 689k | 1.60× | | Write 10k messages, TriG | 829k | 595k | 1.39× |
- Streaming is where this package is furthest ahead: 1.15× to 1.64×, most for tiny chunks. The N3.js lexer works incrementally; by its documentation, rdf-parser-ts runs its parser over every complete statement prefix of the buffered input. I did not profile it to confirm the cause.
- Parsing a whole string is faster here too (1.03× for many small messages, 1.23× to 1.25× for large ones):
parseMessagesis N3.js with an extra directive, and N3.js itself is also ahead of rdf-parser-ts on an ordinary document (1.07×). - Writing: on par for one large N-Quads message (0.99×); rdf-writer-ts is faster for one large TriG message (0.84×); this package is faster for many small messages (1.4× to 1.6×).
- Memory: both stay flat while streaming 100k messages (17 MB): the heap grows by 0.12 MB here and 0.08 MB with rdf-parser-ts.
Beyond speed, the choice is mostly about the ecosystem. Choose n3.js-messages if you already use N3.js (one parser and one set of syntax quirks, n3 as a
shared peer dependency, Node.js 18+, messages as plain Quad[], and a writer that starts every message from a clean state). Choose rdf-parser-ts and rdf-writer-ts if you do not need N3.js, or if writing
large TriG messages is your bottleneck (they need Node.js 22+ and use rdf-data-factory by default).
These are one machine's numbers from a single process: run npm run perf on yours; method and caveats are in perf/README.md.
Development
npm ci
npm test # unit, regression, property tests, with coverage (100% required)
npm run lint
npm run test:spec # official RDF Messages test suite
npm run test:types # TypeScript declarations
npm run test:package # packs the tarball and tests it in a clean consumer project (needs network)
npm run perf
npm pack --dry-runFUZZ_SEED=… FUZZ_ITERATIONS=… repeats the property test with another seed; a failure prints its seed and a minimized log.
License
MIT. The vendored test suite in spec/fixtures keeps its upstream license (spec/fixtures/LICENSE-UPSTREAM.md).
