@factoidal/core 0.20.0 → 0.22.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/CHANGELOG.md +82 -0
- package/README.md +38 -2
- package/authz/server.mjs +23 -2
- package/bin/engine.mjs +22 -1
- package/bin/factoidal.mjs +710 -38
- package/bin/pack-host.mjs +5 -0
- package/bin/serve.mjs +19 -3
- package/browser.d.ts +14 -0
- package/browser.js +16 -0
- package/index.d.ts +325 -9
- package/index.mjs +19 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +303 -12
- package/lib/browser-engine.mjs +28 -1
- package/lib/cid-node.mjs +77 -0
- package/lib/cid.mjs +146 -0
- package/lib/cipher-host.mjs +126 -0
- package/lib/engine-integrity.mjs +56 -0
- package/lib/errors.mjs +6 -0
- package/lib/nodes.mjs +234 -11
- package/lib/sbm11.mjs +14 -1
- package/lib/version.mjs +1 -1
- package/lws/README.md +9 -0
- package/lws/client.mjs +19 -2
- package/lws/persist.mjs +169 -0
- package/lws/server.mjs +82 -29
- package/package.json +2 -1
- package/solid/server/index.mjs +79 -13
- package/webdav/server.mjs +154 -25
- package/xmpp/client.mjs +332 -0
- package/xmpp/proxy.mjs +114 -0
package/xmpp/client.mjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// factoidal/xmpp/client.mjs — an OUTGOING XMPP client connection that
|
|
2
|
+
// carries HTTP over XMPP (XEP-0332), in one of two modes.
|
|
3
|
+
// https://github.com/danbri/factoidal/issues/1107. Design record:
|
|
4
|
+
// docs/designissues/2026-10-28-http-over-xmpp.md.
|
|
5
|
+
//
|
|
6
|
+
// connectHttpTarget (step 1, `factoidal webdav-serve CONFIGDIR --xmpp JID`)
|
|
7
|
+
// serves XEP-0332 requests with a local `step` function
|
|
8
|
+
// connectHttpRequester (step 2, `factoidal xmpp-http-proxy`) sends XEP-0332
|
|
9
|
+
// requests to ONE target JID and gives back responses
|
|
10
|
+
//
|
|
11
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
12
|
+
// Open one TCP socket, or one direct-TLS socket (XEP-0368), to an XMPP
|
|
13
|
+
// server; give every text it reads to the engine (`xmppClientFeed`); write
|
|
14
|
+
// every text the engine answers (`send`); carry each HTTP request the engine
|
|
15
|
+
// names (`requests`) to the caller's `step` function and give the response
|
|
16
|
+
// back (`xmppClientAnswer`); in requester mode, give each HTTP request to the
|
|
17
|
+
// engine (`xmppClientRequest`), give it the clock (`xmppClientTick`) and
|
|
18
|
+
// resolve each response the engine names (`responses`); close the socket when
|
|
19
|
+
// the engine says `close`.
|
|
20
|
+
//
|
|
21
|
+
// It makes NO protocol decision. The stream negotiation, SASL SCRAM-SHA-256,
|
|
22
|
+
// the check of the server signature, resource binding, which stanza is an
|
|
23
|
+
// HTTP request or response, who may send one (the allow list), the target,
|
|
24
|
+
// the refusals and limits, timeouts, chunked bodies and every status are
|
|
25
|
+
// decided in formal/lean4/L4Factoidal/XMPP/ (ClientStream, HttpOverXmpp,
|
|
26
|
+
// HttpRequester) and formal/lean4/Wasm/Ops/XmppClient.lean (iron rule 7 of
|
|
27
|
+
// CLAUDE.md). The host supplies the SCRAM client nonce (random bytes) and the
|
|
28
|
+
// clock, and moves bytes.
|
|
29
|
+
//
|
|
30
|
+
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/XmppClient.lean)
|
|
31
|
+
// xmppClientOpen [configJson] -> { ok, handle, send, ... }
|
|
32
|
+
// xmppClientFeed [handle, text] -> { ok, send, requests, responses, bound, close, reason, stage, bufferedBytes }
|
|
33
|
+
// xmppClientAnswer [handle, id, responseJson] -> { ok, send }
|
|
34
|
+
// xmppClientRequest [handle, nowMs, requestJson] -> { ok, id, send, responses }
|
|
35
|
+
// xmppClientTick [handle, nowMs] -> { ok, send, responses }
|
|
36
|
+
// xmppClientClose [handle] -> { ok, send, responses }
|
|
37
|
+
|
|
38
|
+
import { randomBytes } from 'node:crypto'
|
|
39
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
40
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
41
|
+
|
|
42
|
+
/** The ops this host needs from the engine. */
|
|
43
|
+
export const XMPP_CLIENT_OPS = ['xmppClientOpen', 'xmppClientFeed', 'xmppClientAnswer', 'xmppClientClose',
|
|
44
|
+
'xmppClientRequest', 'xmppClientTick']
|
|
45
|
+
|
|
46
|
+
/** An error of this host, as opposed to one the engine reported. */
|
|
47
|
+
export class XmppClientError extends Error {
|
|
48
|
+
constructor (message, detail = {}) {
|
|
49
|
+
super(message)
|
|
50
|
+
this.name = 'XmppClientError'
|
|
51
|
+
this.unknownOp = detail.unknownOp === true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function call (engine, op, args) {
|
|
56
|
+
let envelope
|
|
57
|
+
try {
|
|
58
|
+
envelope = engine.call(op, args)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
61
|
+
if (text.indexOf('unknown op') >= 0) {
|
|
62
|
+
throw new XmppClientError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
|
|
63
|
+
}
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
if (envelope === null || typeof envelope !== 'object') throw new XmppClientError(`${op} answered with no envelope`)
|
|
67
|
+
if (envelope.ok === false) throw new XmppClientError(`${op}: ${envelope.error}`)
|
|
68
|
+
return envelope
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The configuration fields both modes share; values go to the engine unread. */
|
|
72
|
+
function baseConfig (options) {
|
|
73
|
+
const config = {
|
|
74
|
+
jid: options.jid,
|
|
75
|
+
password: typeof options.password === 'string' ? options.password : '',
|
|
76
|
+
clientNonce: randomBytes(24).toString('base64url'),
|
|
77
|
+
tls: options.tls === true
|
|
78
|
+
}
|
|
79
|
+
if (typeof options.host === 'string') config.host = options.host
|
|
80
|
+
if (typeof options.resource === 'string') config.resource = options.resource
|
|
81
|
+
if (typeof options.minIterations === 'number') config.minIterations = options.minIterations
|
|
82
|
+
return config
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open the engine handle and the socket; run the stream until it is bound.
|
|
87
|
+
* `onFeed(answer)` gets every `xmppClientFeed` envelope after the bytes it
|
|
88
|
+
* names are written. Pure byte moving.
|
|
89
|
+
*/
|
|
90
|
+
async function openStream (engine, config, options, onFeed, onClosed) {
|
|
91
|
+
const onError = typeof options.onError === 'function' ? options.onError : () => {}
|
|
92
|
+
const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 16 * 1024 * 1024
|
|
93
|
+
const opened = call(engine, 'xmppClientOpen', [JSON.stringify(config)])
|
|
94
|
+
const handle = opened.handle
|
|
95
|
+
const domain = opened.domain
|
|
96
|
+
if (typeof domain !== 'string') throw new XmppClientError('xmppClientOpen answered with no domain; rebuild the engine')
|
|
97
|
+
const host = opened.host
|
|
98
|
+
const port = typeof options.port === 'number' ? options.port : (options.tls === true ? 5223 : 5222)
|
|
99
|
+
|
|
100
|
+
let socket
|
|
101
|
+
if (options.tls === true) {
|
|
102
|
+
const tls = await import('node:tls')
|
|
103
|
+
socket = tls.connect({ host, port, servername: domain, ...(options.tlsOptions || {}) })
|
|
104
|
+
} else {
|
|
105
|
+
const net = await import('node:net')
|
|
106
|
+
socket = net.connect({ host, port })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let open = true
|
|
110
|
+
let boundJid = null
|
|
111
|
+
let resolveBound
|
|
112
|
+
let rejectBound
|
|
113
|
+
const bound = new Promise((resolve, reject) => { resolveBound = resolve; rejectBound = reject })
|
|
114
|
+
let resolveClosed
|
|
115
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve })
|
|
116
|
+
|
|
117
|
+
function write (texts) {
|
|
118
|
+
for (const t of texts || []) if (!socket.destroyed) socket.write(t)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function finish (reason) {
|
|
122
|
+
if (!open) return
|
|
123
|
+
open = false
|
|
124
|
+
let closing = null
|
|
125
|
+
try { closing = call(engine, 'xmppClientClose', [handle]) } catch (_error) { /* already gone */ }
|
|
126
|
+
if (!socket.destroyed) socket.end()
|
|
127
|
+
if (boundJid === null) rejectBound(new XmppClientError(reason || 'the XMPP connection closed before binding'))
|
|
128
|
+
if (closing !== null && typeof onClosed === 'function') onClosed(closing)
|
|
129
|
+
resolveClosed(reason || '')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decoder = new StringDecoder('utf8')
|
|
133
|
+
socket.on('connect', () => write(opened.send))
|
|
134
|
+
socket.on('secureConnect', () => {})
|
|
135
|
+
socket.on('data', (chunk) => {
|
|
136
|
+
if (!open) return
|
|
137
|
+
const text = decoder.write(chunk)
|
|
138
|
+
if (text.length === 0) return
|
|
139
|
+
let answer
|
|
140
|
+
try {
|
|
141
|
+
answer = call(engine, 'xmppClientFeed', [handle, text])
|
|
142
|
+
} catch (error) {
|
|
143
|
+
onError(error)
|
|
144
|
+
socket.destroy()
|
|
145
|
+
finish(error.message)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
write(answer.send)
|
|
149
|
+
if (typeof answer.bound === 'string' && boundJid === null) {
|
|
150
|
+
boundJid = answer.bound
|
|
151
|
+
resolveBound(boundJid)
|
|
152
|
+
}
|
|
153
|
+
onFeed(answer)
|
|
154
|
+
if (answer.close === true) finish(answer.reason)
|
|
155
|
+
else if (answer.bufferedBytes > maxBuffered) {
|
|
156
|
+
onError(new XmppClientError(`the server sent more than ${maxBuffered} bytes without completing a stanza`))
|
|
157
|
+
finish('buffer limit')
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
socket.on('error', (error) => { onError(error); finish(error.message) })
|
|
161
|
+
socket.on('close', () => finish('the server closed the connection'))
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
opened,
|
|
165
|
+
handle,
|
|
166
|
+
bound,
|
|
167
|
+
closed,
|
|
168
|
+
write,
|
|
169
|
+
isOpen: () => open,
|
|
170
|
+
close () {
|
|
171
|
+
if (open && !socket.destroyed) {
|
|
172
|
+
try {
|
|
173
|
+
const closing = call(engine, 'xmppClientClose', [handle])
|
|
174
|
+
write(closing.send)
|
|
175
|
+
if (typeof onClosed === 'function') onClosed(closing)
|
|
176
|
+
} catch (_error) { /* gone */ }
|
|
177
|
+
}
|
|
178
|
+
finish('')
|
|
179
|
+
return closed
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Connect to an XMPP server as `jid` and serve HTTP over XMPP with `step`.
|
|
186
|
+
* Resolves once the resource is bound (or rejects with the engine's reason).
|
|
187
|
+
*
|
|
188
|
+
* @param {object} options
|
|
189
|
+
* @param {string} options.jid the account, `user@domain` (a resource may follow `/`)
|
|
190
|
+
* @param {string} options.password
|
|
191
|
+
* @param {string} [options.host] the XMPP server's address (default: the JID's domain; no SRV lookup)
|
|
192
|
+
* @param {number} [options.port] default 5222, or 5223 with `tls`
|
|
193
|
+
* @param {boolean} [options.tls] direct TLS (XEP-0368); without it the engine allows plain TCP to a loopback address only
|
|
194
|
+
* @param {object} [options.tlsOptions] passed to `tls.connect` (for example `ca`)
|
|
195
|
+
* @param {string[]} [options.allow] bare JIDs that may send requests
|
|
196
|
+
* @param {string} [options.resource]
|
|
197
|
+
* @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
|
|
198
|
+
* @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
|
|
199
|
+
* @param {Function} options.step `async ({method, target, headers, body}) => ({status, headers, body})`
|
|
200
|
+
* @param {object} [options.engine]
|
|
201
|
+
* @param {Function} [options.onError]
|
|
202
|
+
* @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
|
|
203
|
+
* @returns {Promise<{jid: string, closed: Promise<string>, close: Function}>}
|
|
204
|
+
*/
|
|
205
|
+
export async function connectHttpTarget (options = {}) {
|
|
206
|
+
if (typeof options.step !== 'function') throw new XmppClientError('connectHttpTarget needs step')
|
|
207
|
+
if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpTarget needs jid')
|
|
208
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
209
|
+
const onError = typeof options.onError === 'function' ? options.onError : () => {}
|
|
210
|
+
const config = baseConfig(options)
|
|
211
|
+
config.allow = Array.isArray(options.allow) ? options.allow : []
|
|
212
|
+
if (Array.isArray(options.mounts)) config.mounts = options.mounts
|
|
213
|
+
if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
|
|
214
|
+
|
|
215
|
+
let stream = null
|
|
216
|
+
/** One request the engine named: carried to `step`, answered. */
|
|
217
|
+
async function carry (request) {
|
|
218
|
+
let answer
|
|
219
|
+
try {
|
|
220
|
+
const response = await options.step({
|
|
221
|
+
method: request.method,
|
|
222
|
+
target: request.target,
|
|
223
|
+
headers: request.headers || [],
|
|
224
|
+
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
|
|
225
|
+
})
|
|
226
|
+
answer = {
|
|
227
|
+
status: response.status,
|
|
228
|
+
headers: response.headers || [],
|
|
229
|
+
bodyBase64: Buffer.from(response.body || new Uint8Array(0)).toString('base64')
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
onError(error)
|
|
233
|
+
answer = { status: 500, headers: [['content-type', 'text/plain']], bodyBase64: Buffer.from(`host error: ${error.message}\n`).toString('base64') }
|
|
234
|
+
}
|
|
235
|
+
if (stream === null || !stream.isOpen()) return
|
|
236
|
+
try {
|
|
237
|
+
stream.write(call(engine, 'xmppClientAnswer', [stream.handle, String(request.id), JSON.stringify(answer)]).send)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
onError(error)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
stream = await openStream(engine, config, options, (answer) => {
|
|
243
|
+
for (const r of answer.requests || []) carry(r)
|
|
244
|
+
})
|
|
245
|
+
const jid = await stream.bound
|
|
246
|
+
return {
|
|
247
|
+
jid,
|
|
248
|
+
writableWithoutLogin: stream.opened.writableWithoutLogin || [],
|
|
249
|
+
closed: stream.closed,
|
|
250
|
+
close: () => stream.close()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Connect to an XMPP server as `jid` and send HTTP requests over XMPP to
|
|
256
|
+
* `target` (a full JID) and to no other entity. Resolves once the resource is
|
|
257
|
+
* bound. `request()` resolves with the response the engine names: from the
|
|
258
|
+
* target, or one the engine made (a refusal, 502, 504).
|
|
259
|
+
*
|
|
260
|
+
* @param {object} options the connection options of `connectHttpTarget`, without `allow`, `mounts` and `step`
|
|
261
|
+
* @param {string} options.target the full JID of the serving side
|
|
262
|
+
* @param {string} options.listenHost the address the caller will listen on (the engine accepts 127.0.0.1 only)
|
|
263
|
+
* @param {number} [options.timeoutSeconds] how long a request waits (the engine accepts 1 to 3600; default 60)
|
|
264
|
+
* @param {number} [options.tickMs] how often the clock is given to the engine (default 500)
|
|
265
|
+
* @returns {Promise<{jid: string, target: string, listenHost: string, maxBodyBytes: number,
|
|
266
|
+
* request: Function, closed: Promise<string>, close: Function}>}
|
|
267
|
+
*/
|
|
268
|
+
export async function connectHttpRequester (options = {}) {
|
|
269
|
+
if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpRequester needs jid')
|
|
270
|
+
if (typeof options.target !== 'string') throw new XmppClientError('connectHttpRequester needs target')
|
|
271
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
272
|
+
const config = baseConfig(options)
|
|
273
|
+
config.proxyTarget = options.target
|
|
274
|
+
config.listenHost = typeof options.listenHost === 'string' ? options.listenHost : ''
|
|
275
|
+
if (typeof options.timeoutSeconds === 'number') config.timeoutSeconds = options.timeoutSeconds
|
|
276
|
+
|
|
277
|
+
const waiting = new Map()
|
|
278
|
+
function deliver (responses) {
|
|
279
|
+
for (const r of responses || []) {
|
|
280
|
+
const resolve = waiting.get(r.id)
|
|
281
|
+
if (resolve === undefined) continue
|
|
282
|
+
waiting.delete(r.id)
|
|
283
|
+
resolve({
|
|
284
|
+
status: r.status,
|
|
285
|
+
headers: r.headers || [],
|
|
286
|
+
body: new Uint8Array(Buffer.from(r.bodyBase64 || '', 'base64'))
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const stream = await openStream(engine, config, options,
|
|
291
|
+
(answer) => deliver(answer.responses),
|
|
292
|
+
(closing) => deliver(closing.responses))
|
|
293
|
+
const jid = await stream.bound
|
|
294
|
+
const timer = setInterval(() => {
|
|
295
|
+
if (!stream.isOpen()) return
|
|
296
|
+
try {
|
|
297
|
+
const t = call(engine, 'xmppClientTick', [stream.handle, String(Date.now())])
|
|
298
|
+
stream.write(t.send)
|
|
299
|
+
deliver(t.responses)
|
|
300
|
+
} catch (_error) { /* handle gone */ }
|
|
301
|
+
}, typeof options.tickMs === 'number' ? options.tickMs : 500)
|
|
302
|
+
stream.closed.then(() => clearInterval(timer))
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
jid,
|
|
306
|
+
target: stream.opened.target,
|
|
307
|
+
listenHost: stream.opened.listenHost,
|
|
308
|
+
maxBodyBytes: Number(stream.opened.maxBodyBytes),
|
|
309
|
+
closed: stream.closed,
|
|
310
|
+
/** `{method, target, headers: [[n, v]...], body: Uint8Array}` -> `{status, headers, body}` */
|
|
311
|
+
request (req) {
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
let answer
|
|
314
|
+
try {
|
|
315
|
+
answer = call(engine, 'xmppClientRequest', [stream.handle, String(Date.now()), JSON.stringify({
|
|
316
|
+
method: req.method,
|
|
317
|
+
target: req.target,
|
|
318
|
+
headers: req.headers || [],
|
|
319
|
+
bodyBase64: Buffer.from(req.body || new Uint8Array(0)).toString('base64')
|
|
320
|
+
})])
|
|
321
|
+
} catch (error) {
|
|
322
|
+
reject(error)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
waiting.set(answer.id, resolve)
|
|
326
|
+
stream.write(answer.send)
|
|
327
|
+
deliver(answer.responses)
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
close: () => { clearInterval(timer); return stream.close() }
|
|
331
|
+
}
|
|
332
|
+
}
|
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
|
+
}
|