diff --git a/@vates/xml-rpc/.USAGE.md b/@vates/xml-rpc/.USAGE.md
new file mode 100644
index 0000000000..b5165ee986
--- /dev/null
+++ b/@vates/xml-rpc/.USAGE.md
@@ -0,0 +1,128 @@
+### Formatting
+
+```js
+import { xmlRpcFormatter } from '@vates/xml-rpc/formatter'
+
+// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
+xmlRpcFormatter.format_methodCall({
+ methodName: 'examples.getStateName',
+ params: [40],
+})
+// → {
+// name: 'methodCall',
+// children: [
+// {
+// name: 'methodName',
+// children: [ 'examples.getStateName' ]
+// },
+// {
+// name: 'params',
+// children: [
+// {
+// name: 'param',
+// children: [ { name: 'value', children: [ '40' ] } ]
+// }
+// ]
+// }
+// ]
+// }
+
+// There is a dedicated method to format a JS value
+xmlRpcFormatter.format_js(new Date(900684535000))
+// → {
+// name: 'dateTime.iso8601',
+// children: [ '1998-07-17T14:08:55.000Z' ]
+// }
+```
+
+Formatting XML is out of this library's scope, but you can achieve it easily with
+`@vates/xml`:
+
+```js
+import { formatXml } from '@vates/xml/format'
+
+// it can now be passed to the XML-RPC parser
+const tree = xmlRpcFormatter.format_methodCall({
+ methodName: 'examples.getStateName',
+ params: [40],
+})
+
+// avoid any indentation or new lines as it will confuse many XML-RPC parsers.
+const xml = formatXml(tree, { indent: 0 })
+```
+
+### Parsing
+
+```js
+import { xmlRpcParser } from '@vates/xml-rpc/parser'
+
+xmlRpcParser.parse({
+ name: 'methodResponse',
+ children: [
+ {
+ name: 'params',
+ children: [
+ {
+ name: 'param',
+ children: [
+ {
+ name: 'value',
+ children: [
+ {
+ name: 'string',
+ children: ['South Dakota'],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+})
+// { params: [ 'South Dakota' ] }
+
+// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
+xmlRpcParser.parse_array({
+ name: 'array',
+ children: [
+ {
+ name: 'data',
+ children: [
+ {
+ name: 'value',
+ children: [{ name: 'boolean', children: ['1'] }],
+ },
+ {
+ name: 'value',
+ children: [{ name: 'int', children: ['42'] }],
+ },
+ ],
+ },
+ ],
+})
+// [ true, 42 ]
+```
+
+Parsing XML is out of this library's scope, but you can achieve it easily with
+`@vates/xml`:
+
+```js
+import { parseXml } from '@vates/xml/parse'
+
+const xml = `
+
+
+
+
+ South Dakota
+
+
+
+`
+
+const tree = parseXml(xml)
+
+// it can now be passed to the XML-RPC parser
+xmlRpcParser.parse(tree)
+```
diff --git a/@vates/xml-rpc/.npmignore b/@vates/xml-rpc/.npmignore
new file mode 120000
index 0000000000..008d1b9b98
--- /dev/null
+++ b/@vates/xml-rpc/.npmignore
@@ -0,0 +1 @@
+../../scripts/npmignore
\ No newline at end of file
diff --git a/@vates/xml-rpc/README.md b/@vates/xml-rpc/README.md
new file mode 100644
index 0000000000..149d05a7e7
--- /dev/null
+++ b/@vates/xml-rpc/README.md
@@ -0,0 +1,161 @@
+
+
+# @vates/xml-rpc
+
+[](https://npmjs.org/package/@vates/xml-rpc)  [](https://bundlephobia.com/result?p=@vates/xml-rpc) [](https://npmjs.org/package/@vates/xml-rpc)
+
+> Parse and format XML-RPC messages, do not handle transport or XML
+
+## Install
+
+Installation of the [npm package](https://npmjs.org/package/@vates/xml-rpc):
+
+```sh
+npm install --save @vates/xml-rpc
+```
+
+## Usage
+
+### Formatting
+
+```js
+import { xmlRpcFormatter } from '@vates/xml-rpc/formatter'
+
+// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
+xmlRpcFormatter.format_methodCall({
+ methodName: 'examples.getStateName',
+ params: [40],
+})
+// → {
+// name: 'methodCall',
+// children: [
+// {
+// name: 'methodName',
+// children: [ 'examples.getStateName' ]
+// },
+// {
+// name: 'params',
+// children: [
+// {
+// name: 'param',
+// children: [ { name: 'value', children: [ '40' ] } ]
+// }
+// ]
+// }
+// ]
+// }
+
+// There is a dedicated method to format a JS value
+xmlRpcFormatter.format_js(new Date(900684535000))
+// → {
+// name: 'dateTime.iso8601',
+// children: [ '1998-07-17T14:08:55.000Z' ]
+// }
+```
+
+Formatting XML is out of this library's scope, but you can achieve it easily with
+`@vates/xml`:
+
+```js
+import { formatXml } from '@vates/xml/format'
+
+// it can now be passed to the XML-RPC parser
+const tree = xmlRpcFormatter.format_methodCall({
+ methodName: 'examples.getStateName',
+ params: [40],
+})
+
+// avoid any indentation or new lines as it will confuse many XML-RPC parsers.
+const xml = formatXml(tree, { indent: 0 })
+```
+
+### Parsing
+
+```js
+import { xmlRpcParser } from '@vates/xml-rpc/parser'
+
+xmlRpcParser.parse({
+ name: 'methodResponse',
+ children: [
+ {
+ name: 'params',
+ children: [
+ {
+ name: 'param',
+ children: [
+ {
+ name: 'value',
+ children: [
+ {
+ name: 'string',
+ children: ['South Dakota'],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+})
+// { params: [ 'South Dakota' ] }
+
+// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
+xmlRpcParser.parse_array({
+ name: 'array',
+ children: [
+ {
+ name: 'data',
+ children: [
+ {
+ name: 'value',
+ children: [{ name: 'boolean', children: ['1'] }],
+ },
+ {
+ name: 'value',
+ children: [{ name: 'int', children: ['42'] }],
+ },
+ ],
+ },
+ ],
+})
+// [ true, 42 ]
+```
+
+Parsing XML is out of this library's scope, but you can achieve it easily with
+`@vates/xml`:
+
+```js
+import { parseXml } from '@vates/xml/parse'
+
+const xml = `
+
+
+
+
+ South Dakota
+
+
+
+`
+
+const tree = parseXml(xml)
+
+// it can now be passed to the XML-RPC parser
+xmlRpcParser.parse(tree)
+```
+
+## Contributions
+
+Contributions are _very_ welcomed, either on the documentation or on
+the code.
+
+You may:
+
+- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
+ you've encountered;
+- fork and create a pull request.
+
+## License
+
+[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
diff --git a/@vates/xml-rpc/formatter.js b/@vates/xml-rpc/formatter.js
new file mode 100644
index 0000000000..2d5f042e15
--- /dev/null
+++ b/@vates/xml-rpc/formatter.js
@@ -0,0 +1,104 @@
+'use strict'
+
+function node(name, attributes, children) {
+ if (Array.isArray(attributes)) {
+ children = attributes
+ attributes = undefined
+ }
+
+ return { name, attributes, children }
+}
+
+class XmlRpcFormatter {
+ format_js(value) {
+ const type = typeof value
+ if (type === 'object') {
+ return value === null
+ ? this.format_nil()
+ : value instanceof Date
+ ? this['format_dateTime.iso8601'](value)
+ : Array.isArray(value)
+ ? this.format_array(value)
+ : Buffer.isBuffer(value)
+ ? this.format_base64(value)
+ : this.format_struct(value)
+ }
+
+ return this['format_' + type](value)
+ }
+
+ format_array(values) {
+ return node('array', [node('data', values.map(this.format_value, this))])
+ }
+
+ format_base64(buf) {
+ return node('base64', [buf.toString('base64')])
+ }
+
+ format_boolean(bool) {
+ return node('boolean', [bool ? '1' : '0'])
+ }
+
+ 'format_dateTime.iso8601'(date) {
+ return node('dateTime.iso8601', [date.toISOString()])
+ }
+
+ format_double(str) {
+ return node('double', [String(str)])
+ }
+
+ format_fault(val) {
+ return node('fault', [this.format_value(val)])
+ }
+
+ format_i4(str) {
+ return node('i4', [String(str)])
+ }
+
+ format_int(str) {
+ return node('int', [String(str)])
+ }
+
+ format_member([name, value]) {
+ return node('member', [node('name', [name]), this.format_value(value)])
+ }
+
+ format_methodCall({ methodName, params }) {
+ return node('methodCall', [node('methodName', [methodName]), this.format_params(params)])
+ }
+
+ format_methodResponse({ fault, params }) {
+ return node('methodResponse', [fault !== undefined ? this.format_fault(fault) : this.format_params(params)])
+ }
+
+ format_nil() {
+ return node('nil')
+ }
+
+ format_number(num) {
+ return Number.isInteger(num) ? this.format_int(num) : this.format_double(num)
+ }
+
+ format_param(value) {
+ return node('param', [this.format_value(value)])
+ }
+
+ format_params(params) {
+ return node('params', params.map(this.format_param, this))
+ }
+
+ format_string(str) {
+ return str
+ }
+
+ format_struct(struct) {
+ return node('struct', Object.entries(struct).map(this.format_member, this))
+ }
+
+ format_value(value) {
+ return node('value', [this.format_js(value)])
+ }
+}
+exports.Formatter = XmlRpcFormatter
+
+exports.xmlRpcFormatter = new XmlRpcFormatter()
diff --git a/@vates/xml-rpc/package.json b/@vates/xml-rpc/package.json
new file mode 100644
index 0000000000..0bd88ebd9e
--- /dev/null
+++ b/@vates/xml-rpc/package.json
@@ -0,0 +1,33 @@
+{
+ "private": false,
+ "name": "@vates/xml-rpc",
+ "description": "Parse and format XML-RPC messages, do not handle transport or XML",
+ "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/xml-rpc",
+ "bugs": "https://github.com/vatesfr/xen-orchestra/issues",
+ "repository": {
+ "directory": "@vates/xml-rpc",
+ "type": "git",
+ "url": "https://github.com/vatesfr/xen-orchestra.git"
+ },
+ "author": {
+ "name": "Vates SAS",
+ "url": "https://vates.fr"
+ },
+ "license": "ISC",
+ "version": "0.0.0",
+ "engines": {
+ "node": ">=12"
+ },
+ "devDependencies": {
+ "@vates/xml": "0.0.0",
+ "test": "^3.3.0"
+ },
+ "exports": {
+ "./formatter": "./formatter.js",
+ "./parser": "./parser.js"
+ },
+ "scripts": {
+ "postversion": "npm publish --access public",
+ "test": "node--test"
+ }
+}
diff --git a/@vates/xml-rpc/parser.js b/@vates/xml-rpc/parser.js
new file mode 100644
index 0000000000..1fcf76f3d1
--- /dev/null
+++ b/@vates/xml-rpc/parser.js
@@ -0,0 +1,133 @@
+'use strict'
+
+function toValue(node) {
+ const obj = { __proto__: null }
+ for (const child of node.children) {
+ if (typeof child === 'string') {
+ return child
+ }
+
+ const { name } = child
+ if (Object.hasOwn(obj, name)) {
+ const prev = obj[name]
+ if (Array.isArray(obj, name)) {
+ prev.push(child)
+ } else {
+ obj[name] = [prev, child]
+ }
+ } else {
+ obj[name] = child
+ }
+ }
+ return obj
+}
+
+// https://en.wikipedia.org/wiki/XML-RPC#Data_types
+class XmlRpcParser {
+ parse(node) {
+ return this['parse_' + node.name](node)
+ }
+
+ parse_array({ children: [data] }) {
+ return data.children.map(this.parse_value, this)
+ }
+
+ parse_base64({ children: [str] }) {
+ return Buffer.from(str, 'base64')
+ }
+
+ parse_boolean({ children: [str] }) {
+ return Boolean(Number(str))
+ }
+
+ 'parse_dateTime.iso8601'({ children: [str] }) {
+ const date = new Date(str)
+
+ // date successfully parsed
+ if (!Number.isNaN(+date)) {
+ return date
+ }
+
+ // XML-RPC dates may use YYYYMMDD instead of YYYY-MM-DD which ECMAScript
+ // does not parse
+ return new Date(str.slice(0, 4) + '-' + str.slice(4, 6) + '-' + str.slice(6))
+ }
+
+ parse_double({ children: [str] }) {
+ return Number(str)
+ }
+
+ parse_fault({ children: [value] }) {
+ return this.parse_value(value)
+ }
+
+ parse_i4({ children: [str] }) {
+ return Number(str)
+ }
+
+ parse_int({ children: [str] }) {
+ return Number(str)
+ }
+
+ parse_member(member) {
+ member = toValue(member)
+ return [member.name.children[0], this.parse_value(member.value)]
+ }
+
+ parse_methodCall(methodCall) {
+ methodCall = toValue(methodCall)
+ return {
+ methodName: methodCall.methodName.children[0],
+ params: this.parse_params(methodCall.params),
+ }
+ }
+
+ parse_methodResponse(methodResponse) {
+ methodResponse = toValue(methodResponse)
+
+ const { fault } = methodResponse
+ if (fault !== undefined) {
+ return { fault: this.parse_fault(fault) }
+ }
+
+ return { params: this.parse_params(methodResponse.params) }
+ }
+
+ parse_nil() {
+ return null
+ }
+
+ parse_param(param) {
+ return this.parse_value(param.children[0])
+ }
+
+ parse_params(params) {
+ return params.children.map(this.parse_param, this)
+ }
+
+ parse_string({ children: [str] }) {
+ return str
+ }
+
+ parse_struct(struct) {
+ return Object.fromEntries(struct.children.map(this.parse_member, this))
+ }
+
+ parse_value({ children }) {
+ if (children.length === 0) {
+ return ''
+ }
+
+ const child = children[0]
+
+ // can be a plain string
+ if (typeof child === 'string') {
+ return child
+ }
+
+ return this.parse(child)
+ }
+}
+exports.XmlRpcParser = XmlRpcParser
+
+exports.xmlRpcParser = new XmlRpcParser()
diff --git a/@vates/xml-rpc/test/formatter.js b/@vates/xml-rpc/test/formatter.js
new file mode 100644
index 0000000000..17fc11ff87
--- /dev/null
+++ b/@vates/xml-rpc/test/formatter.js
@@ -0,0 +1,19 @@
+'use strict'
+
+const { formatXml } = require('@vates/xml/format')
+const { join } = require('node:path')
+const { parseXml } = require('@vates/xml/parse')
+const { readFileSync } = require('node:fs')
+const { xmlRpcFormatter } = require('@vates/xml-rpc/formatter')
+const assert = require('node:assert/strict')
+const test = require('test')
+
+const methodCall = require('./methodCall.js')
+
+test(function () {
+ const xmlTree = parseXml(readFileSync(join(__dirname, 'methodCall.xml')))
+
+ const xmlRpc = xmlRpcFormatter.format_methodCall(methodCall)
+
+ assert.deepEqual(parseXml(formatXml(xmlRpc)), xmlTree)
+})
diff --git a/@vates/xml-rpc/test/methodCall.js b/@vates/xml-rpc/test/methodCall.js
new file mode 100644
index 0000000000..4ff407d97e
--- /dev/null
+++ b/@vates/xml-rpc/test/methodCall.js
@@ -0,0 +1,16 @@
+'use strict'
+
+module.exports = {
+ methodName: 'examples.getStateName',
+ params: [
+ [1404, 'Something here'],
+ Buffer.from([121, 111, 117, 32, 99, 97, 110, 39, 116, 32, 114, 101, 97, 100, 32, 116, 104, 105, 115, 33]),
+ true,
+ new Date('1998-07-17T14:08:55.000Z'),
+ -12.53,
+ 42,
+ 'Hello world!',
+ { foo: 1, bar: 2 },
+ null,
+ ],
+}
diff --git a/@vates/xml-rpc/test/methodCall.xml b/@vates/xml-rpc/test/methodCall.xml
new file mode 100644
index 0000000000..e163f678db
--- /dev/null
+++ b/@vates/xml-rpc/test/methodCall.xml
@@ -0,0 +1,73 @@
+
+
+ examples.getStateName
+
+
+
+
+
+
+ 1404
+
+
+ Something here
+
+
+
+
+
+
+
+ eW91IGNhbid0IHJlYWQgdGhpcyE=
+
+
+
+
+ 1
+
+
+
+
+ 1998-07-17T14:08:55.000Z
+
+
+
+
+ -12.53
+
+
+
+
+ 42
+
+
+
+
+ Hello world!
+
+
+
+
+
+
+ foo
+
+ 1
+
+
+
+ bar
+
+ 2
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/@vates/xml-rpc/test/parser.js b/@vates/xml-rpc/test/parser.js
new file mode 100644
index 0000000000..8976078f4f
--- /dev/null
+++ b/@vates/xml-rpc/test/parser.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const { join } = require('node:path')
+const { parseXml } = require('@vates/xml/parse')
+const { readFileSync } = require('node:fs')
+const { xmlRpcParser } = require('@vates/xml-rpc/parser')
+const assert = require('node:assert/strict')
+const test = require('test')
+
+const methodCall = require('./methodCall.js')
+
+test('parser.parse()', function () {
+ const xml = String(readFileSync(join(__dirname, 'methodCall.xml')))
+ const xmlRpc = parseXml(xml)
+
+ assert.deepEqual(xmlRpcParser.parse(xmlRpc), methodCall)
+})
+
+test('datetime without dashes nor milliseconds', function () {
+ assert.deepEqual(
+ xmlRpcParser.parse({ name: 'dateTime.iso8601', children: ['19980717T14:08:55Z'] }),
+ new Date('1998-07-17T14:08:55Z')
+ )
+})
+
+test('i4 support', function () {
+ assert.equal(xmlRpcParser.parse({ name: 'i4', children: ['42'] }), 42)
+})
+
+test('string support', function () {
+ assert.equal(xmlRpcParser.parse({ name: 'string', children: ['foo'] }), 'foo')
+})
diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md
index d23d4449b3..91b22a0865 100644
--- a/CHANGELOG.unreleased.md
+++ b/CHANGELOG.unreleased.md
@@ -28,5 +28,6 @@
- @vates/xml major
+- @vates/xml-rpc major