strophe.js
Version:
Strophe.js is an XMPP library for JavaScript
204 lines (190 loc) • 7.99 kB
text/typescript
/**
* This file implements an incremental XML stream parser for the XEP-0114
* external-component transport (see {@link Component} in `component.ts`).
*
* A raw TCP stream has no message framing: bytes arrive in arbitrary chunks and
* a single, never-closing `<stream:stream>` root has to be parsed incrementally
* into its top-level child stanzas. This is the SAX push parser that
* `websocket.ts` anticipated in its "[TODO : We may actually want to use a SAX
* Push parser]" note.
*
* The parser is deliberately Node-only: it relies on `node:string_decoder` to
* reassemble multi-byte UTF-8 characters split across TCP reads and on `saxes`
* (a small, well-tested SAX tokenizer) for tokenization.
* The actual DOM construction reuses Strophe's own {@link xmlGenerator}, so the
* `Element` objects handed downstream are the exact same type produced by the
* BOSH and WebSocket transports.
*/
import { Buffer } from 'node:buffer';
import { createRequire } from 'node:module';
import { StringDecoder } from 'node:string_decoder';
import type { SaxesParser, SaxesTagNS } from 'saxes';
import { xmlGenerator } from '../utils';
/** Cached `saxes` constructor, resolved by {@link getSaxesParser} on first use. */
let SaxesParserCtor: typeof import('saxes').SaxesParser | undefined;
/**
* Resolve the `saxes` parser constructor, loading the package on first use.
*
* `saxes` is an optional peer dependency needed only by this transport, so it is
* deliberately *not* imported at module load.
*
* The load is synchronous because {@link Component} builds its parser in its
* constructor.
*/
function getSaxesParser(): typeof import('saxes').SaxesParser {
if (!SaxesParserCtor) {
try {
SaxesParserCtor = createRequire(import.meta.url)('saxes').SaxesParser;
} catch {
throw new Error(
'Strophe: the XEP-0114 component transport requires the "saxes" package, ' +
'which is an optional peer dependency. Install it with `npm install saxes`.',
);
}
}
return SaxesParserCtor;
}
export interface StreamParserHandlers {
/** Called once the opening `<stream:stream …>` header is seen, with its
* attributes (most importantly `id`). The header never closes until the
* stream ends, so this fires as soon as its start tag is complete. */
onStreamStart: (attrs: Record<string, string>) => void;
/** Called with each complete top-level stanza as a DOM {@link Element}. */
onStanza: (stanza: Element) => void;
/** Called when the closing `</stream:stream>` tag is received. */
onStreamEnd: () => void;
/** Called on any XML parse error. The stream should be considered dead. */
onError: (error: Error) => void;
}
/**
* Incrementally parses an XMPP component stream.
*
* Feed it raw socket chunks via {@link ComponentParser#write}; it emits
* stream-start, per-stanza and stream-end events through the handlers passed to
* the constructor. Robust to a stanza (or a single multi-byte character) being
* split across arbitrary chunk boundaries.
*/
export default class ComponentParser {
private readonly _handlers: StreamParserHandlers;
private _decoder: StringDecoder;
private _parser: SaxesParser<{ xmlns: true }>;
/** Stack of elements currently being built (the in-progress stanza subtree).
* Empty in between top-level stanzas. Excludes the stream root, which is
* never materialised as an element. */
private _stack: Element[];
/** Whether the opening `<stream:stream>` header has been seen. */
private _streamOpened: boolean;
/** Set after a fatal parse error so that further writes are ignored. */
private _errored: boolean;
constructor(handlers: StreamParserHandlers) {
this._handlers = handlers;
this.reset();
}
/**
* Discard all parser state and start afresh. Called when a (re)connection
* begins so a new stream is parsed from a clean slate.
*/
reset(): void {
this._decoder = new StringDecoder('utf8');
this._stack = [];
this._streamOpened = false;
this._errored = false;
const SaxesParserClass = getSaxesParser();
this._parser = new SaxesParserClass<{ xmlns: true }>({ xmlns: true });
this._parser.on('opentag', (tag) => this._onOpenTag(tag));
this._parser.on('closetag', () => this._onCloseTag());
this._parser.on('text', (text) => this._onText(text));
this._parser.on('cdata', (cdata) => this._onText(cdata));
this._parser.on('error', (err) => this._fail(err));
}
/**
* Feed a chunk of raw stream data into the parser.
*
* Accepts a `Buffer` (as delivered by a `node:net` socket) or an already
* decoded string. Buffers are run through a {@link StringDecoder} so that a
* multi-byte UTF-8 character split across two chunks is reassembled instead
* of corrupted.
* @param chunk - The bytes (or string) received from the socket.
*/
write(chunk: Buffer | string): void {
if (this._errored) {
return;
}
const str = typeof chunk === 'string' ? chunk : this._decoder.write(chunk);
if (str === '') {
return;
}
try {
this._parser.write(str);
} catch (e) {
this._fail(e as Error);
}
}
private _fail(error: Error): void {
if (this._errored) {
return;
}
this._errored = true;
this._handlers.onError(error);
}
private _onOpenTag(tag: SaxesTagNS): void {
if (!this._streamOpened) {
// The depth-0 element is the stream root. Capture its attributes and
// treat it as the stream-start event; it won't close until the
// stream ends.
this._streamOpened = true;
this._handlers.onStreamStart(this._flattenAttributes(tag));
return;
}
const el = this._createElement(tag);
if (this._stack.length) {
this._stack[this._stack.length - 1].appendChild(el);
}
this._stack.push(el);
}
private _onText(text: string): void {
// Text (and whitespace keepalives) directly under the stream root, i.e.
// in between stanzas, is ignored.
if (this._stack.length) {
this._stack[this._stack.length - 1].appendChild(xmlGenerator().createTextNode(text));
}
}
private _onCloseTag(): void {
if (!this._stack.length) {
// Nothing is being built, so this closes the stream root.
this._streamOpened = false;
this._handlers.onStreamEnd();
return;
}
const el = this._stack.pop()!;
if (!this._stack.length) {
// A complete top-level stanza has been assembled.
this._handlers.onStanza(el);
}
}
private _createElement(tag: SaxesTagNS): Element {
const el = xmlGenerator().createElementNS(tag.uri || null, tag.name);
for (const key of Object.keys(tag.attributes)) {
const attr = tag.attributes[key];
// Namespace declarations are skipped: createElementNS already
// carries the resolved URI, so re-adding xmlns/xmlns:* would only
// duplicate them on serialisation.
if (attr.name === 'xmlns' || attr.prefix === 'xmlns') {
continue;
}
if (attr.uri) {
el.setAttributeNS(attr.uri, attr.name, attr.value);
} else {
el.setAttribute(attr.name, attr.value);
}
}
return el;
}
private _flattenAttributes(tag: SaxesTagNS): Record<string, string> {
const attrs: Record<string, string> = {};
for (const key of Object.keys(tag.attributes)) {
attrs[key] = tag.attributes[key].value;
}
return attrs;
}
}