strophe.js
Version:
Strophe.js is an XMPP library for JavaScript
687 lines (628 loc) • 20.5 kB
text/typescript
import log from './log';
import { ElementType, PARSE_ERROR_NS, XHTML } from './constants';
export type XHTMLAttrs =
'a' | 'blockquote' | 'br' | 'cite' | 'em' | 'img' | 'li' | 'ol' | 'p' | 'span' | 'strong' | 'ul' | 'body';
export interface CookieValue {
value: string;
domain?: string;
path?: string;
expires?: string;
}
export type Cookies = Record<string, string | CookieValue>;
/**
* Takes a string and turns it into an XML Element.
* @param string
* @param throwErrorIfInvalidNS
* @returns
*/
export function toElement(string: string, throwErrorIfInvalidNS?: boolean): Element {
const doc = xmlHtmlNode(string);
const parserError = getParserError(doc);
if (parserError) {
throw new Error(`Parser Error: ${parserError}`);
}
const node = getFirstElementChild(doc)!;
if (
['message', 'iq', 'presence'].includes(node.nodeName.toLowerCase()) &&
node.namespaceURI !== 'jabber:client' &&
node.namespaceURI !== 'jabber:server'
) {
const err_msg = `Invalid namespaceURI ${node.namespaceURI}`;
if (throwErrorIfInvalidNS) {
throw new Error(err_msg);
} else {
log.error(err_msg);
}
}
return node;
}
/**
* Properly logs an error to the console
* @param e
*/
export function handleError(e: Error): void {
if (typeof e.stack !== 'undefined') {
log.fatal(e.stack);
}
log.fatal('error: ' + e.message);
}
/**
* @param str
* @returns
*/
export function utf16to8(str: string): string {
let out = '';
const len = str.length;
for (let i = 0; i < len; i++) {
const c = str.charCodeAt(i);
if (c >= 0x0000 && c <= 0x007f) {
out += str.charAt(i);
} else if (c > 0x07ff) {
out += String.fromCharCode(0xe0 | ((c >> 12) & 0x0f));
out += String.fromCharCode(0x80 | ((c >> 6) & 0x3f));
out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f));
} else {
out += String.fromCharCode(0xc0 | ((c >> 6) & 0x1f));
out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f));
}
}
return out;
}
/**
* @param x
* @param y
* @returns
*/
export function xorArrayBuffers(x: ArrayBuffer, y: ArrayBuffer): ArrayBuffer {
const xIntArray = new Uint8Array(x);
const yIntArray = new Uint8Array(y);
const zIntArray = new Uint8Array(x.byteLength);
for (let i = 0; i < x.byteLength; i++) {
zIntArray[i] = xIntArray[i] ^ yIntArray[i];
}
return zIntArray.buffer;
}
/**
* @param buffer
* @returns
*/
export function arrayBufToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* @param str
* @returns
*/
export function base64ToArrayBuf(str: string): ArrayBuffer {
return Uint8Array.from(atob(str), (c) => c.charCodeAt(0))?.buffer!;
}
/**
* @param str
* @returns
*/
export function stringToArrayBuf(str: string): ArrayBuffer {
const bytes = new TextEncoder().encode(str);
return bytes.buffer;
}
/**
* @param cookies
*/
export function addCookies(cookies?: Cookies): void {
if (typeof document === 'undefined') {
log.error(`addCookies: not adding any cookies, since there's no document object`);
return;
}
const cookieMap = cookies || {};
for (const cookieName in cookieMap) {
if (Object.prototype.hasOwnProperty.call(cookieMap, cookieName)) {
let expires = '';
let domain = '';
let path = '';
const cookieObj = cookieMap[cookieName];
const isObj = typeof cookieObj === 'object';
const cookieValue = escape(unescape(isObj ? (cookieObj as CookieValue).value : (cookieObj as string)));
if (isObj) {
const cv = cookieObj as CookieValue;
expires = cv.expires ? ';expires=' + cv.expires : '';
domain = cv.domain ? ';domain=' + cv.domain : '';
path = cv.path ? ';path=' + cv.path : '';
}
document.cookie = cookieName + '=' + cookieValue + expires + domain + path;
}
}
}
let _xmlGenerator: Document | null = null;
/**
* Get the DOM document to generate elements.
* @returns The currently used DOM document.
*/
export function xmlGenerator(): Document {
if (!_xmlGenerator) {
_xmlGenerator = document.implementation.createDocument('jabber:client', 'strophe', null);
}
return _xmlGenerator;
}
/**
* Creates an XML DOM text node.
* Provides a cross implementation version of document.createTextNode.
* @param text - The content of the text node.
* @returns A new XML DOM text node.
*/
export function xmlTextNode(text: string): Text {
return xmlGenerator().createTextNode(text);
}
/**
* @param stanza
* @returns
*/
export function stripWhitespace(stanza: Element): Element {
const childNodes = Array.from(stanza.childNodes);
if (childNodes.length === 1 && childNodes[0].nodeType === ElementType.TEXT) {
return stanza;
}
childNodes.forEach((node) => {
if (node.nodeName.toLowerCase() === 'body') {
return;
}
if (node.nodeType === ElementType.TEXT && !/\S/.test(node.nodeValue)) {
stanza.removeChild(node);
} else if (node.nodeType === ElementType.NORMAL) {
stripWhitespace(node as Element);
}
});
return stanza;
}
/**
* Creates an XML DOM node.
* @param text - The contents of the XML element.
* @returns
*/
export function xmlHtmlNode(text: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(text, 'text/xml') as XMLDocument;
}
/**
* @param doc
* @returns
*/
export function getParserError(doc: XMLDocument): string | null {
const el =
doc.firstElementChild?.nodeName === 'parsererror'
? doc.firstElementChild
: doc.getElementsByTagNameNS(PARSE_ERROR_NS, 'parsererror')[0];
return el?.nodeName === 'parsererror' ? el?.textContent : null;
}
/**
* @param el
* @returns
*/
export function getFirstElementChild(el: XMLDocument): Element | null {
if (el.firstElementChild) return el.firstElementChild;
let node: Node | undefined;
let i = 0;
const nodes = el.childNodes;
while ((node = nodes[i++])) {
if (node.nodeType === 1) return node as Element;
}
return null;
}
type XmlElementAttrs = Array<Array<string>> | Record<string, string | number> | string | number;
/**
* Create an XML DOM element.
*
* This function creates an XML DOM element correctly across all
* implementations. Note that these are not HTML DOM elements, which
* aren't appropriate for XMPP stanzas.
*
* @param name - The name for the element.
* @param attrs
* An optional array or object containing
* key/value pairs to use as element attributes.
* The object should be in the format `{'key': 'value'}`.
* The array should have the format `[['key1', 'value1'], ['key2', 'value2']]`.
* @param text - The text child data for the element.
*
* @returns A new XML DOM element.
*/
export function xmlElement(name: string, attrs?: XmlElementAttrs, text?: string | number): Element | null {
if (!name) return null;
const node = xmlGenerator().createElement(name);
if (text && (typeof text === 'string' || typeof text === 'number')) {
node.appendChild(xmlTextNode(text.toString()));
} else if (typeof attrs === 'string' || typeof attrs === 'number') {
node.appendChild(xmlTextNode(attrs.toString()));
return node;
}
if (!attrs) {
return node;
} else if (Array.isArray(attrs)) {
for (const attr of attrs) {
if (Array.isArray(attr)) {
if (attr[0] != null && attr[1] != null) {
node.setAttribute(attr[0], attr[1]);
}
}
}
} else if (typeof attrs === 'object') {
for (const k of Object.keys(attrs)) {
if (k && attrs[k] != null) {
node.setAttribute(k, attrs[k].toString());
}
}
}
return node;
}
/**
* Utility method to determine whether a tag is allowed
* in the XHTML_IM namespace.
*
* XHTML tag names are case sensitive and must be lower case.
* @method Strophe.XHTML.validTag
* @param tag
*/
export function validTag(tag: string): boolean {
for (let i = 0; i < XHTML.tags.length; i++) {
if (tag === XHTML.tags[i]) {
return true;
}
}
return false;
}
/**
* Utility method to determine whether an attribute is allowed
* as recommended per XEP-0071
*
* XHTML attribute names are case sensitive and must be lower case.
* @method Strophe.XHTML.validAttribute
* @param tag
* @param attribute
*/
export function validAttribute(tag: string, attribute: string): boolean {
const attrs = XHTML.attributes[tag as XHTMLAttrs];
if (attrs?.length > 0) {
for (let i = 0; i < attrs.length; i++) {
if (attribute === attrs[i]) {
return true;
}
}
}
return false;
}
/**
* @method Strophe.XHTML.validCSS
* @param style
*/
export function validCSS(style: string): boolean {
for (let i = 0; i < XHTML.css.length; i++) {
if (style === XHTML.css[i]) {
return true;
}
}
return false;
}
/**
* Copy an HTML DOM Element into an XML DOM.
* This function copies a DOM element and all its descendants and returns
* the new copy.
* @param elem - A DOM element.
* @returns A new, copied DOM element tree.
*/
function createFromHtmlElement(elem: HTMLElement): Node {
let el: Node;
const tag = elem.nodeName.toLowerCase();
if (validTag(tag)) {
try {
el = xmlElement(tag)!;
if (tag in XHTML.attributes) {
const attrs = XHTML.attributes[tag as XHTMLAttrs];
for (let i = 0; i < attrs.length; i++) {
const attribute = attrs[i];
let value = elem.getAttribute(attribute);
if (typeof value === 'undefined' || value === null || value === '') {
continue;
}
if (attribute === 'style' && typeof value === 'object') {
value = (value as CSSStyleDeclaration).cssText ?? value;
}
if (attribute === 'style') {
const css: string[] = [];
const cssAttrs = value.split(';');
for (let j = 0; j < cssAttrs.length; j++) {
const attr = cssAttrs[j].split(':');
const cssName = attr[0].replace(/^\s*/, '').replace(/\s*$/, '').toLowerCase();
if (validCSS(cssName)) {
const cssValue = attr[1].replace(/^\s*/, '').replace(/\s*$/, '');
css.push(cssName + ': ' + cssValue);
}
}
if (css.length > 0) {
value = css.join('; ');
(el as Element).setAttribute(attribute, value);
}
} else {
(el as Element).setAttribute(attribute, value);
}
}
for (let i = 0; i < elem.childNodes.length; i++) {
(el as Element).appendChild(createHtml(elem.childNodes[i])!);
}
}
} catch (_e) {
el = xmlTextNode('');
}
} else {
el = xmlGenerator().createDocumentFragment();
for (let i = 0; i < elem.childNodes.length; i++) {
(el as DocumentFragment).appendChild(createHtml(elem.childNodes[i])!);
}
}
return el;
}
/**
* Copy an HTML DOM Node into an XML DOM.
* This function copies a DOM element and all its descendants and returns
* the new copy.
* @method Strophe.createHtml
* @param node - A DOM element.
* @returns A new, copied DOM element tree.
*/
export function createHtml(node: Node): Node | undefined {
if (node.nodeType === ElementType.NORMAL) {
return createFromHtmlElement(node as HTMLElement);
} else if (node.nodeType === ElementType.FRAGMENT) {
const el = xmlGenerator().createDocumentFragment();
for (let i = 0; i < node.childNodes.length; i++) {
el.appendChild(createHtml(node.childNodes[i])!);
}
return el;
} else if (node.nodeType === ElementType.TEXT) {
return xmlTextNode(node.nodeValue);
}
}
/**
* Copy an XML DOM element.
*
* This function copies a DOM element and all its descendants and returns
* the new copy.
* @method Strophe.copyElement
* @param node - A DOM element.
* @returns A new, copied DOM element tree.
*/
export function copyElement(node: Node): Element | Text | undefined {
let out: Element | Text | undefined;
if (node.nodeType === ElementType.NORMAL) {
const el = node as Element;
out = xmlElement(el.tagName)!;
for (let i = 0; i < el.attributes.length; i++) {
out.setAttribute(el.attributes[i].nodeName, el.attributes[i].value);
}
for (let i = 0; i < el.childNodes.length; i++) {
out.appendChild(copyElement(el.childNodes[i])!);
}
} else if (node.nodeType === ElementType.TEXT) {
out = xmlGenerator().createTextNode(node.nodeValue);
}
return out;
}
/**
* Excapes invalid xml characters.
* @method Strophe.xmlescape
* @param text - text to escape.
* @returns Escaped text.
*/
export function xmlescape(text: string): string {
return text
.replace(/\&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/'/g, ''')
.replace(/"/g, '"');
}
/**
* Unexcapes invalid xml characters.
* @method Strophe.xmlunescape
* @param text - text to unescape.
* @returns Unescaped text.
*/
export function xmlunescape(text: string): string {
text = text.replace(/\&/g, '&');
text = text.replace(/</g, '<');
text = text.replace(/>/g, '>');
text = text.replace(/'/g, "'");
text = text.replace(/"/g, '"');
return text;
}
/**
* Map a function over some or all child elements of a given element.
*
* This is a small convenience function for mapping a function over
* some or all of the children of an element. If elemName is null, all
* children will be passed to the function, otherwise only children
* whose tag names match elemName will be passed.
*
* @method Strophe.forEachChild
* @param elem - The element to operate on.
* @param elemName - The child element tag name filter.
* @param func - The function to apply to each child. This
* function should take a single argument, a DOM element.
*/
export function forEachChild(elem: Element, elemName: string, func: (child: Element) => void): void {
for (let i = 0; i < elem.childNodes.length; i++) {
const childNode = elem.childNodes[i];
if (childNode.nodeType === ElementType.NORMAL && (!elemName || isTagEqual(childNode as Element, elemName))) {
func(childNode as Element);
}
}
}
/**
* Compare an element's tag name with a string.
* This function is case sensitive.
* @method Strophe.isTagEqual
* @param el - A DOM element.
* @param name - The element name.
* @returns
* true if the element's tag name matches _el_, and false
* otherwise.
*/
export function isTagEqual(el: Element, name: string): boolean {
return el.tagName === name;
}
/**
* Return the XML namespace of an element.
*
* Prefers the serialized `xmlns` attribute and falls back to the DOM
* `namespaceURI`, because the two diverge depending on how the element was
* built and neither is reliable on its own:
*
* - Locally-built stanzas (`$iq`, `stx`, {@link Builder}) are created with
* `createElement` and carry their namespace only in the `xmlns` attribute;
* their `namespaceURI` is null.
* - Stanzas received over the XEP-0114 component transport are built with
* `createElementNS` and carry their namespace only on `namespaceURI`; the
* redundant `xmlns` attribute is omitted.
* - WebSocket / BOSH stanzas parsed by `DOMParser` carry both, except on
* child elements that inherit the default namespace without redeclaring it
* (those have only `namespaceURI`).
*
* Checking both is the transport-agnostic way to read an element's namespace.
*
* @method Strophe.getNamespace
* @param elem - The element whose namespace is wanted.
* @returns The namespace URI, or null if the element has none.
*/
export function getNamespace(elem: Element): string | null {
return elem.getAttribute('xmlns') || elem.namespaceURI;
}
/**
* Get the concatenation of all text children of an element.
* @method Strophe.getText
* @param elem - A DOM element.
* @returns A String with the concatenated text of all text element children.
*/
export function getText(elem: Node | null): string | null {
if (!elem) return null;
let str = '';
if (!elem.childNodes.length && elem.nodeType === ElementType.TEXT) {
str += elem.nodeValue;
}
for (const child of elem.childNodes) {
if (child.nodeType === ElementType.TEXT) {
str += child.nodeValue;
}
}
return xmlescape(str);
}
/**
* Escape the node part (also called local part) of a JID.
* @method Strophe.escapeNode
* @param node - A node (or local part).
* @returns An escaped node (or local part).
*/
export function escapeNode(node: unknown): unknown {
if (typeof node !== 'string') {
return node;
}
return node
.replace(/^\s+|\s+$/g, '')
.replace(/\\/g, '\\5c')
.replace(/ /g, '\\20')
.replace(/\"/g, '\\22')
.replace(/\&/g, '\\26')
.replace(/\'/g, '\\27')
.replace(/\//g, '\\2f')
.replace(/:/g, '\\3a')
.replace(/</g, '\\3c')
.replace(/>/g, '\\3e')
.replace(/@/g, '\\40');
}
/**
* Unescape a node part (also called local part) of a JID.
* @method Strophe.unescapeNode
* @param node - A node (or local part).
* @returns An unescaped node (or local part).
*/
export function unescapeNode(node: unknown): unknown {
if (typeof node !== 'string') {
return node;
}
return node
.replace(/\\20/g, ' ')
.replace(/\\22/g, '"')
.replace(/\\26/g, '&')
.replace(/\\27/g, "'")
.replace(/\\2f/g, '/')
.replace(/\\3a/g, ':')
.replace(/\\3c/g, '<')
.replace(/\\3e/g, '>')
.replace(/\\40/g, '@')
.replace(/\\5c/g, '\\');
}
/**
* Get the node portion of a JID String.
* @method Strophe.getNodeFromJid
* @param jid - A JID.
* @returns A String containing the node.
*/
export function getNodeFromJid(jid: string): string | null {
if (jid.indexOf('@') < 0) {
return null;
}
return jid.split('@')[0];
}
/**
* Get the domain portion of a JID String.
* @method Strophe.getDomainFromJid
* @param jid - A JID.
* @returns A String containing the domain.
*/
export function getDomainFromJid(jid: string): string | null {
const bare = getBareJidFromJid(jid);
if (bare!.indexOf('@') < 0) {
return bare;
} else {
const parts = bare!.split('@');
parts.splice(0, 1);
return parts.join('@');
}
}
/**
* Get the resource portion of a JID String.
* @method Strophe.getResourceFromJid
* @param jid - A JID.
* @returns A String containing the resource.
*/
export function getResourceFromJid(jid: string): string | null {
if (!jid) {
return null;
}
const s = jid.split('/');
if (s.length < 2) {
return null;
}
s.splice(0, 1);
return s.join('/');
}
/**
* Get the bare JID from a JID String.
* @method Strophe.getBareJidFromJid
* @param jid - A JID.
* @returns A String containing the bare JID.
*/
export function getBareJidFromJid(jid: string): string | null {
return jid ? jid.split('/')[0] : null;
}
const utils = {
utf16to8,
xorArrayBuffers,
arrayBufToBase64,
base64ToArrayBuf,
stringToArrayBuf,
addCookies,
};
export { utils as default };