Skip to content

About

XEP-0332 HTTP over XMPP for TypeScript: all seven body transports, client and server, Node and browsers, a gateway CLI, and httpx:// browsers as a WebExtension, an Electron shell, an Android app and a Dillo plugin

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

xmpp-httpx

CI npm API docs

TypeScript implementation of XEP-0332: HTTP over XMPP Transport. It tunnels HTTP requests and responses through XMPP, for Node.js and browsers.

XEP-0332 is a Deferred XEP (v0.5.1). This library is an exploratory implementation of the kind the XEP explicitly encourages, built as the foundation for a browser that navigates httpx://user@domain/path URLs.

Documentation:

Doc Contents
docs/architecture.md Design rules, module map, request lifecycle, all seven transports, error & security models, configuration reference
docs/protocol-notes.md Every decision made where the spec is ambiguous; the interop anchor
docs/testing.md The three vitest projects, mock-session harness, Prosody E2E, CI
docs/browser-extension.md WebExtension architecture: connection placement, rendering pipeline, tabs, manifest strategy
docs/electron-shell.md The desktop shell: httpx:// as a scheme Chromium fetches, and why that changes the design
docs/dillo-plugin.md The Dillo plugin: httpx:// as a dpi, and why Dillo is the easiest host of the three
docs/gateway-cli.md xmpp-httpx-gateway: put an existing HTTP origin on XMPP, by hand or in Docker
docs/ROADMAP.md Next phases and tasks (release, hardening, extension v2, gateway product)
docs/interop.md Interoperability matrix: tested servers, runtimes, peer implementations
examples/webext/README.md Build/run guide for the browser extension
examples/dillo/README.md Install guide for the Dillo plugin
CHANGELOG.md Release history

What's implemented

  • Requester (client) and responder (server) sides of the protocol
  • All seven body transports of the XEP: inline text / xml / base64, chunkedBase64 message streams, IBB (XEP-0047, implemented here since no upstream xmpp.js package exists), sipub (XEP-0137 over SI, IBB stream method), and a Jingle session (XEP-0166/0234) over either the XEP-0261 IBB transport or XEP-0260 SOCKS5 bytestreams with candidate negotiation and automatic IBB fallback. sipub/jingle are opt-in for sending (preferredStreams), always accepted on receive
  • SHIM headers (XEP-0131), httpx:// URL parsing, service discovery (XEP-0030), entity caps (XEP-0115) with presence-based capability caching
  • A fetch()-shaped API returning real WHATWG Response objects with streaming bodies
  • A reverse-proxy handler for gateway deployments, and a forward proxy (destination policy, request and CONNECT handlers) for exits (xmpp-httpx/node)
  • A ready-made gateway CLI (xmpp-httpx-gateway) with a Docker image
  • A published test harness (xmpp-httpx/testing) so downstream code can be tested without an XMPP server

Install (NOT PUBLISHED ON NPM YET)

npm install xmpp-httpx @xmpp/client

ESM-only, Node ≥ 20.10 or any evergreen browser. @xmpp/client (or @xmpp/component) is a peer dependency: this library never opens connections; you hand it a connected session.

Client

import { client, xml } from "@xmpp/client";
import { HttpxClient, httpxFetch } from "xmpp-httpx";

const xmpp = client({ service: "wss://example.org/xmpp-websocket", username: "alice", password: "…" });
await xmpp.start();

// fetch()-style — returns a WHATWG Response
const response = await httpxFetch("httpx://webserver@example.org/index.html", {
  session: xmpp,
});
console.log(response.status, await response.text());

// Lower-level API with full control
const httpx = new HttpxClient(xmpp, { maxChunkSize: 4096 });
const resp = await httpx.request("webserver@example.org", {
  method: "POST",
  resource: "/api/items",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ name: "thing" }),
});
console.log(resp.statusCode, await resp.json());

Response bodies are ReadableStream<Uint8Array>: large bodies stream progressively regardless of which mechanism (inline, chunked messages, IBB) carried them, and .text(), .json(), .bytes(), .xml() and .formData() are all there. XMPP-level failures (forbidden, timeout, unreachable) throw HttpxError with an httpEquivalent status; only real <resp> stanzas produce responses.

Per request you can set timeoutMs (the IQ deadline), idleTimeoutMs (the gap allowed between pieces of a streamed body), and a signal; AbortSignal.timeout(5000) is all a caller needs for an overall deadline.

Server

import { HttpxServer, allowList } from "xmpp-httpx";

const server = new HttpxServer(xmpp, {
  authorize: allowList(["alice@example.org", "*@trusted.example"]),
});

server.handle(async (req) => {
  // req: { from, to, method, resource, url, headers, body, accept }
  if (req.resource === "/hello") {
    return { status: 200, headers: { "content-type": "text/plain" }, body: "hi" };
  }
  // WHATWG Responses work directly — reverse-proxying is one line:
  return fetch(new URL(req.resource, "http://localhost:8080"));
});

server.start();

Authorization is deny-all by default, per the XEP's security considerations; pass allowAll(), allowList(...), or your own policy. withRateLimit(handler, { ratePerSecond }) throttles per requester, and negotiateContentType(req.headers.get("accept"), ["text/html", "application/json"]) picks a representation without getting q-values wrong. The server picks the response encoding automatically: small bodies inline into the IQ, large ones stream via IBB or chunked messages, honoring the requester's advertised maxChunkSize and mechanism flags.

For gateway deployments (an XMPP component fronting a real web server):

import { component } from "@xmpp/component";
import { HttpxServer, allowAll } from "xmpp-httpx";
import { createOriginProxyHandler } from "xmpp-httpx/node";

const gw = component({ service: "xmpp://localhost:5347", domain: "web.example.org", password: "…" });
const server = new HttpxServer(gw, { authorize: allowAll() });
server.handle(createOriginProxyHandler("http://localhost:8080"));
server.start();

Tunnels (CONNECT)

A CONNECT answered with a tunnel carries bytes both ways over one IBB session. This is a deliberate extension to XEP-0332 v0.5.1, which excludes CONNECT; see protocol-notes.md. The library never dials anything. The handler connects to the destination and hands back the pipe:

import net from "node:net";
import { once } from "node:events";
import { pipeline } from "node:stream/promises";

const exit = new HttpxServer(xmpp, { authorize: allowList(["alice@example.org"]), tunnels: true });
exit.handle(async (req) => {
  if (req.method !== "CONNECT") return { status: 405 };
  const colon = req.resource.lastIndexOf(":"); // authority-form: host:port, IPv6 in brackets
  const host = req.resource.slice(0, colon).replace(/^\[(.*)\]$/, "$1");
  const socket = net.connect(Number(req.resource.slice(colon + 1)), host);
  try {
    await once(socket, "connect");
  } catch {
    return { status: 502 };
  }
  return {
    status: 200,
    tunnel: async (tunnel) => {
      // The destination may have hung up while the <open> was on its way.
      if (socket.destroyed) return tunnel.close().catch(() => {});
      socket.on("close", () => void tunnel.close().catch(() => {}));
      socket.on("data", (chunk) => {
        socket.pause(); // write() resolves when the window has room
        tunnel.write(chunk).then(() => socket.resume(), () => socket.destroy());
      });
      try {
        await pipeline(tunnel.readable, socket); // waits for 'drain' the other way
      } finally {
        socket.destroy();
      }
    },
  };
});
exit.start();

// The other end:
const { response, tunnel } = await httpx.connect("exit@example.org", { authority: "example.org:443" });

tunnels: true advertises urn:xmpp:http:connect:0. Without it, CONNECT is answered 501 and never reaches the handler. connect() refuses a peer whose disco lacks the feature. There is no half-close: close() on either side ends both directions. A tunnel has no idle watchdog, so noticing a peer that has vanished is up to the application.

The handler above dials whatever it is asked to, which is fine for a test and wrong for anything reachable by others. An exit open to other people needs a destination policy. xmpp-httpx/node has the one n146 runs. By default it allows the public internet on ports 80 and 443 only, and refuses private, loopback, link-local and cloud-metadata addresses and the host's own. It checks the address actually dialled, after name resolution, so DNS rebinding cannot slip past it:

import { DestinationPolicy, createConnectHandler, createForwardProxyHandler } from "xmpp-httpx/node";

const policy = new DestinationPolicy();           // { allowPorts, allowPrivate } to widen it
const tunnels = createConnectHandler({ policy });  // CONNECT: dial, then pipe
const forward = createForwardProxyHandler({ policy }); // http(s) requests in absolute-form
exit.handle((req) => (req.method === "CONNECT" ? tunnels(req) : forward(req)));

Refusals are ordinary statuses (403 for the policy, 502/504 when the destination fails), and the forward handler never follows a redirect. bridgeTunnel(socket, tunnel) is the two-way pipe both handlers use, with backpressure in both directions, for the client side of a tunnel too.

Gateway CLI

Put an existing website on XMPP without writing any code:

XMPP_HTTPX_SECRET=… npx xmpp-httpx-gateway \
  --origin http://localhost:8080 \
  --service xmpp://xmpp.example.org:5347 \
  --domain web.example.org \
  --allow alice@example.org

That serves httpx://web.example.org/… from your HTTP origin, forwarding each requester's SASL-authenticated JID as X-Httpx-From. Client-account mode (--jid/--password) needs no server-side configuration at all. Authorization is never implicit: --allow <jid> or --allow-all, or the gateway refuses to start. Everything can live in a JSON config file (--config), with flags > environment > file precedence. See docs/gateway-cli.md.

--static <dir> serves a directory with no HTTP origin at all; --rate/--burst throttle per requester (a real 429 with Retry-After); --metrics-port exposes Prometheus metrics and a /healthz probe.

For a deployment, examples/docker/ is a three-container stack (nginx origin, Prosody, gateway) that goes from docker compose up to a browsable httpx://web.localhost/ in two commands.

Development

npm install
npm run lint && npm run typecheck   # ESLint + tsc
npm test                            # vitest: unit + in-memory integration suite
npm run build                       # emit dist/
npm run demo                        # Prosody + demo site, one command

The integration suite runs both endpoints against an in-memory stanza router with fault injection (reordered chunk delivery), so the full protocol, including IBB flow control, is exercised without a real XMPP server. That harness is published as xmpp-httpx/testing, so your own handlers can be tested the same way:

import { createSessionPair } from "xmpp-httpx/testing";

const [clientSession, serverSession] = createSessionPair("alice@example.org/pc", "web@example.org");
// …put an HttpxServer on one, an HttpxClient on the other, and assert.

The browser

examples/webext/ is a working WebExtension for Firefox and Chromium that navigates httpx:// URLs with this library: tabs with per-tab history, an address bar, a history/bookmarks drawer, an omnibox keyword (httpx server@example.org/page ⏎), clickable ext+httpx:// links on Firefox, and a sanitized rendering pipeline (DOMPurify + a CSSOM CSS sanitizer → blob-URL subresources → script-less sandboxed iframe) with forms, downloads, page titles/favicons and an HTTP cache doing real 304 revalidation. See its README for build/run instructions, scripts/demo-gateway.mjs for a demo site to browse, and npm run smoke to drive the whole thing in real Chromium.

The desktop shell

examples/electron/ goes further than the extension can: protocol.handle("httpx", …) makes httpx:// a scheme Chromium itself fetches, so the address bar is real and subresources, forms, history and downloads work as they do for http, with no blob-URL rewriting anywhere.

cd examples/electron && npm install && npm start

Page scripts are off by default (a CSP the protocol handler imposes over the server's), content lives in sandboxed views with no preload, and account JIDs are encoded into the host because a Request URL cannot carry credentials. With an exit set, ordinary https:// and http:// addresses load through it: HTTPS as a CONNECT tunnel, so TLS stays end to end. With none, they do not load at all. See docs/electron-shell.md.

The Dillo plugin

examples/dillo/ teaches Dillo the scheme through its plugin interface: a server dpi that dpid starts on the first httpx:// request and that keeps one XMPP session signed in across page loads. Dillo fetches a page's images through the same plugin and renders everything itself. It has no JavaScript engine, so none of the extension's sanitizing exists here; the plugin is a protocol mapping and nothing more.

cd examples/dillo && npm install && npm run install:dillo
# edit ~/.dillo/httpx.json, then open httpx://… in Dillo

Verified against a real dpid and Dillo 3.0.5 under Xvfb (npm run smoke:dillo). For the ordinary web, point Dillo's http_proxy at an n146 daemon; HTTPS then travels as a CONNECT tunnel through the exit (see the example's README). See docs/dillo-plugin.md.

Roadmap

Done so far: the protocol with all seven transports, the browser extension, the gateway (CLI, Docker image, metrics, static-site mode, rate limiting) and the desktop shell. Next:

  • Jingle S5B (XEP-0260) transport candidate negotiation
  • Packaging the shell, which is what an OS-level httpx: registration needs
  • Publishing to npm and the extension stores

Full detail, including everything already finished, in docs/ROADMAP.md.

License

AGPL-3.0-only

About

XEP-0332 HTTP over XMPP for TypeScript: all seven body transports, client and server, Node and browsers, a gateway CLI, and httpx:// browsers as a WebExtension, an Electron shell, an Android app and a Dillo plugin

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages