@factoidal/core 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/xmpp/proxy.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // factoidal/xmpp/proxy.mjs — `factoidal xmpp-http-proxy`: a local HTTP
2
+ // listener whose every request is sent over XMPP (XEP-0332) to ONE target
3
+ // JID, so a file browser, rclone, curl or our LWS client can use
4
+ // http://127.0.0.1:PORT/ to reach `webdav-serve --xmpp` behind NAT.
5
+ // https://github.com/danbri/factoidal/issues/1107 (step 2). Design record:
6
+ // docs/designissues/2026-10-28-http-over-xmpp.md, section "Step 2".
7
+ //
8
+ // WHAT THIS FILE IS ALLOWED TO DO
9
+ // Listen on the address the engine accepted (`listenHost`, 127.0.0.1 only in
10
+ // this step), read each HTTP request (method, request target, raw header
11
+ // pairs, body up to the engine's `maxBodyBytes` plus one byte), give it to
12
+ // `connectHttpRequester().request`, and write the status, header pairs and
13
+ // body it resolves with. No protocol decision: which headers are forwarded,
14
+ // where the stanzas go, the refusals, limits, timeouts and every status are
15
+ // decided in formal/lean4/L4Factoidal/XMPP/HttpRequester.lean and
16
+ // formal/lean4/Wasm/Ops/XmppClient.lean.
17
+
18
+ import http from 'node:http'
19
+ import { connectHttpRequester } from './client.mjs'
20
+
21
+ /**
22
+ * Start the proxy. Resolves when the XMPP stream is bound and the listener
23
+ * is open.
24
+ *
25
+ * @param {object} options the options of `connectHttpRequester` (jid,
26
+ * password, target, host, port, tls, tlsOptions, timeoutSeconds, engine,
27
+ * onError), plus:
28
+ * @param {string} [options.listenHost] default 127.0.0.1 (the engine refuses any other)
29
+ * @param {number} [options.listenPort] default 8091; 0 takes an ephemeral port
30
+ * @returns {Promise<{origin: string, jid: string, target: string, closed: Promise<string>, close: Function}>}
31
+ */
32
+ export async function startHttpProxy (options = {}) {
33
+ const onError = typeof options.onError === 'function' ? options.onError : () => {}
34
+ const conn = await connectHttpRequester({
35
+ ...options,
36
+ listenHost: typeof options.listenHost === 'string' ? options.listenHost : '127.0.0.1'
37
+ })
38
+ const limit = conn.maxBodyBytes
39
+
40
+ const server = http.createServer((req, res) => {
41
+ const parts = []
42
+ let size = 0
43
+ req.on('data', (b) => {
44
+ // Keep at most limit + 1 bytes: enough for the engine to answer 413.
45
+ if (size > limit) return
46
+ parts.push(b)
47
+ size += b.length
48
+ })
49
+ req.on('end', async () => {
50
+ const headers = []
51
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) headers.push([req.rawHeaders[i], req.rawHeaders[i + 1]])
52
+ let body = Buffer.concat(parts)
53
+ if (body.length > limit + 1) body = body.subarray(0, limit + 1)
54
+ let answer
55
+ try {
56
+ answer = await conn.request({ method: req.method, target: req.url, headers, body })
57
+ } catch (error) {
58
+ onError(error)
59
+ answer = { status: 502, headers: [['content-type', 'text/plain; charset=utf-8']], body: Buffer.from(`proxy host error: ${error.message}\n`) }
60
+ }
61
+ const flat = []
62
+ for (const [n, v] of answer.headers) flat.push(n, v)
63
+ try {
64
+ res.writeHead(answer.status, flat)
65
+ res.end(Buffer.from(answer.body))
66
+ } catch (error) {
67
+ onError(error)
68
+ if (!res.headersSent) { res.writeHead(502); res.end() } else res.destroy()
69
+ }
70
+ })
71
+ req.on('error', (error) => onError(error))
72
+ })
73
+ // Node gives CONNECT as its own event, with the raw socket. The request
74
+ // goes to the engine like any other (it answers 405); the answer is written
75
+ // on the socket, which is then closed. No tunnel is ever opened.
76
+ server.on('connect', async (req, socket) => {
77
+ const headers = []
78
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) headers.push([req.rawHeaders[i], req.rawHeaders[i + 1]])
79
+ let answer
80
+ try {
81
+ answer = await conn.request({ method: req.method, target: req.url, headers, body: new Uint8Array(0) })
82
+ } catch (error) {
83
+ onError(error)
84
+ answer = { status: 502, headers: [], body: new Uint8Array(0) }
85
+ }
86
+ const body = Buffer.from(answer.body)
87
+ const lines = [`HTTP/1.1 ${answer.status} ${http.STATUS_CODES[answer.status] || ''}`]
88
+ for (const [n, v] of answer.headers) lines.push(`${n}: ${v}`)
89
+ lines.push(`content-length: ${body.length}`, 'connection: close', '', '')
90
+ socket.end(Buffer.concat([Buffer.from(lines.join('\r\n'), 'latin1'), body]))
91
+ })
92
+ const port = typeof options.listenPort === 'number' ? options.listenPort : 8091
93
+ try {
94
+ await new Promise((resolve, reject) => {
95
+ server.once('error', reject)
96
+ server.listen(port, conn.listenHost, resolve)
97
+ })
98
+ } catch (error) {
99
+ await conn.close()
100
+ throw error
101
+ }
102
+ const addr = server.address()
103
+ conn.closed.then(() => server.close())
104
+ return {
105
+ origin: `http://${addr.address}:${addr.port}/`,
106
+ jid: conn.jid,
107
+ target: conn.target,
108
+ closed: conn.closed,
109
+ async close () {
110
+ server.close()
111
+ await conn.close()
112
+ }
113
+ }
114
+ }