@factoidal/core 0.19.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/webdav/server.mjs CHANGED
@@ -15,11 +15,31 @@
15
15
  // `webdavStep`, then carry what the engine answers: an `lws` request to
16
16
  // the local storage it names (no socket: the storage handle's own step),
17
17
  // a `fetch` to the URL it names, until it answers a `response`, which
18
- // this file writes back.
18
+ // this file writes back; when the response carries `write` (the state
19
+ // file `.webdav-state.json`: dead properties and locks), replace that file
20
+ // first (a new file in the same directory, mode 0600, then a rename);
21
+ // * with a users file (`--users`, https://github.com/danbri/factoidal/issues/1108),
22
+ // give its path to `webdavOpen` and the bytes the engine names; when the
23
+ // engine answers `need` (a login: one PBKDF2-HMAC-SHA256 derivation), run
24
+ // exactly that WebCrypto call (`answerCipherRequest`, lib/cipher-host.mjs)
25
+ // and give the answer to `webdavResume`; give each request's client
26
+ // address (the socket's `remoteAddress`) to `webdavStep`, for the
27
+ // failed-login limit the engine keeps
28
+ // (https://github.com/danbri/factoidal/issues/1119); pass the limit
29
+ // options unread and print the `warnings` of `ready`;
30
+ // * with `--cert` and `--key`, listen with Node `https` (TLS) instead of
31
+ // `http`;
32
+ // * with `--guest NAME` (https://github.com/danbri/factoidal/issues/1147),
33
+ // pass the name unread: the engine decides that a request with no
34
+ // credentials acts as that user, and which mounts each user reaches.
35
+ //
36
+ // Expert review of the login: https://github.com/danbri/factoidal/issues/1117
19
37
  //
20
38
  // It makes NO protocol decision. Which files are mounts, what a mount file
21
39
  // says, every status code, header field, XML body, lock, property and path
22
- // mapping is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
40
+ // mapping, every login decision (which users file lines count, whether a
41
+ // password matches, the login cache, whether Basic may run on this address)
42
+ // is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
23
43
  // CLAUDE.md). The one status code written here is 500, for a failure of
24
44
  // this host itself. Requests are served one at a time (a queue), because
25
45
  // the engine keeps one request in flight per handle.
@@ -27,14 +47,17 @@
27
47
  // THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
28
48
  // webdavOpen [configJson] -> { ok, handle, need }
29
49
  // webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
30
- // webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
31
- // webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
50
+ // webdavStep [handle, requestJson] + body -> { ok, response [+ write] | lws | fetch | need } + body
51
+ // webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch | need } + body
32
52
  // webdavClose [handle] -> { ok }
33
53
 
34
- import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
35
- import { resolve as resolvePath } from 'node:path'
54
+ import {
55
+ readFileSync, readdirSync, writeFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync
56
+ } from 'node:fs'
57
+ import { resolve as resolvePath, dirname, basename, join } from 'node:path'
36
58
  import { loadEngine } from '../bin/engine.mjs'
37
59
  import { openLwsStorage } from '../lws/server.mjs'
60
+ import { answerCipherRequest } from '../lib/cipher-host.mjs'
38
61
 
39
62
  /** The ops this host needs from the engine. */
40
63
  export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
@@ -107,12 +130,45 @@ function gather (configDir, need) {
107
130
  return { files, exists }
108
131
  }
109
132
 
133
+ /**
134
+ * Replace a file the engine named (`write` on a response) atomically: write
135
+ * a new file in the same directory, flush it, then rename it over the old
136
+ * one, so a crash leaves the old text or the new text. Pure I/O.
137
+ */
138
+ function replaceFile (configDir, write) {
139
+ const target = resolvePath(configDir, write.path)
140
+ const suffix = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(6))).toString('hex')
141
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${suffix}.tmp`)
142
+ const fd = openSync(temp, 'wx', parseInt(write.mode, 8))
143
+ try {
144
+ writeSync(fd, write.text)
145
+ fsyncSync(fd)
146
+ } finally {
147
+ closeSync(fd)
148
+ }
149
+ try {
150
+ renameSync(temp, target)
151
+ } catch (error) {
152
+ try { unlinkSync(temp) } catch (_error) { /* already gone */ }
153
+ throw error
154
+ }
155
+ }
156
+
110
157
  /**
111
158
  * Open a WebDAV server handle over the mount files of `configDir`.
112
159
  *
113
160
  * @param {object} options
114
161
  * @param {string} options.configDir the directory of mount files
115
162
  * @param {object} [options.engine] an engine handle to reuse
163
+ * @param {string} [options.users] the users file (an absolute path, or one
164
+ * relative to configDir): the listener then needs a login
165
+ * @param {boolean} [options.tls] the listener uses TLS
166
+ * @param {string} [options.host] the address the listener binds
167
+ * @param {string} [options.loginLimit] `--login-limit`, passed unread
168
+ * @param {string} [options.loginWindow] `--login-window`, passed unread
169
+ * @param {boolean} [options.disableRateLimits] `--dangerously-disable-ratelimits`
170
+ * @param {string} [options.guest] `--guest NAME`: the user a request with no
171
+ * credentials acts as (passed unread)
116
172
  * @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
117
173
  */
118
174
  export async function openWebdav (options = {}) {
@@ -120,7 +176,22 @@ export async function openWebdav (options = {}) {
120
176
  const configDir = options.configDir
121
177
  const engine = options.engine !== undefined ? options.engine : await loadEngine()
122
178
  const listing = readdirSync(configDir)
123
- let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify({ listing, entropy: randomBase64(32) })])
179
+ const login = {
180
+ users: typeof options.users === 'string' ? options.users : null,
181
+ tls: options.tls === true,
182
+ host: typeof options.host === 'string' ? options.host : '127.0.0.1',
183
+ limit: typeof options.loginLimit === 'string' ? options.loginLimit : null,
184
+ window: typeof options.loginWindow === 'string' ? options.loginWindow : null,
185
+ disableRateLimits: options.disableRateLimits === true
186
+ }
187
+ // The guest user (issue 1147), passed unread; the engine checks it.
188
+ if (typeof options.guest === 'string') login.guest = options.guest
189
+ const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
190
+ // The Host and Origin guard of the HTTP listener (issue 1124): the bound
191
+ // address and the operator's names and origins, passed unread
192
+ // (L4Factoidal/HTTP/LocalGuard.lean decides).
193
+ if (options.localGuard !== undefined && options.localGuard !== null) config.localGuard = options.localGuard
194
+ let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify(config)])
124
195
  if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
125
196
  const handle = envelope.handle
126
197
  let loads = 0
@@ -137,10 +208,25 @@ export async function openWebdav (options = {}) {
137
208
  }
138
209
  // Each local storage, opened as lws-serve opens it; the config is the
139
210
  // engine's, passed unread.
211
+ // Each takes the lock of its directory (one writing process per storage
212
+ // directory, https://github.com/danbri/factoidal/issues/1135): a directory
213
+ // that lws-serve or another webdav-serve holds stops the start, and the
214
+ // locks of the mounts opened before it are released.
140
215
  const storages = new Map()
141
- for (const l of ready.local || []) {
142
- const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true })
143
- storages.set(l.name, storage)
216
+ const storageNotices = []
217
+ try {
218
+ for (const l of ready.local || []) {
219
+ const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true, lockCommand: 'webdav-serve' })
220
+ storages.set(l.name, storage)
221
+ for (const n of storage.notices || []) storageNotices.push(`mount ${l.name}: ${n}`)
222
+ }
223
+ } catch (error) {
224
+ for (const s of storages.values()) s.close()
225
+ try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
226
+ if (error !== null && typeof error === 'object' && error.envelope !== undefined && !(error instanceof WebdavHostError)) {
227
+ throw new WebdavHostError(error.envelope.error, { envelope: error.envelope })
228
+ }
229
+ throw error
144
230
  }
145
231
 
146
232
  /** Carry one `fetch` the engine named. Pure I/O. */
@@ -168,12 +254,14 @@ export async function openWebdav (options = {}) {
168
254
  target: request.target,
169
255
  headers: request.headers,
170
256
  now: nowSeconds(),
171
- randomBase64: randomBase64(16)
257
+ randomBase64: randomBase64(16),
258
+ remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
172
259
  }
173
260
  let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
174
261
  for (let hop = 0; hop < MAX_HOPS; hop += 1) {
175
262
  const env = got.envelope
176
263
  if (env.response !== undefined) {
264
+ if (env.write !== undefined) replaceFile(configDir, env.write)
177
265
  return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
178
266
  }
179
267
  let answer
@@ -189,8 +277,12 @@ export async function openWebdav (options = {}) {
189
277
  const x = await exchange(env.fetch, got.bytes)
190
278
  answer = x.answer
191
279
  bytes = x.bytes
280
+ } else if (env.need !== undefined) {
281
+ // A login: the one WebCrypto derivation the engine named.
282
+ answer = await answerCipherRequest(env.need)
283
+ bytes = new Uint8Array(0)
192
284
  } else {
193
- throw new WebdavHostError('the engine answered neither response, lws nor fetch')
285
+ throw new WebdavHostError('the engine answered neither response, lws, fetch nor need')
194
286
  }
195
287
  got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
196
288
  }
@@ -201,9 +293,13 @@ export async function openWebdav (options = {}) {
201
293
  handle,
202
294
  engine,
203
295
  mounts: ready.mounts || [],
296
+ warnings: ready.warnings || [],
297
+ /** The start notices of the local mounts (a stale lock taken over,
298
+ * encryption at rest on or off), for standard error. */
299
+ storageNotices,
204
300
  /**
205
301
  * One request, served after the requests before it.
206
- * @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
302
+ * @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array, remoteAddress?: string}} request
207
303
  */
208
304
  step (request) {
209
305
  const run = queue.then(() => serve(request))
@@ -217,6 +313,16 @@ export async function openWebdav (options = {}) {
217
313
  }
218
314
  }
219
315
 
316
+ /** The addresses of this machine's interfaces. Pure I/O. */
317
+ async function interfaceAddresses () {
318
+ try {
319
+ const os = await import('node:os')
320
+ return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
321
+ } catch (_error) {
322
+ return []
323
+ }
324
+ }
325
+
220
326
  function readBody (request) {
221
327
  return new Promise((resolve, reject) => {
222
328
  const chunks = []
@@ -246,20 +352,36 @@ function writeHeaders (response, pairs) {
246
352
  * Start the server: open the handle, then listen.
247
353
  *
248
354
  * @param {object} options `configDir`, `port` (default 8090), `host`
249
- * (default 127.0.0.1), `engine`, `onError`
355
+ * (default 127.0.0.1), `engine`, `onError`, `users` (a users file),
356
+ * `cert` and `key` (PEM bytes: listen with TLS), `loginLimit`,
357
+ * `loginWindow`, `disableRateLimits` (the failed-login limit)
250
358
  * @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
251
359
  */
252
360
  export async function listen (options = {}) {
253
- const http = await import('node:http')
254
- const dav = await openWebdav(options)
361
+ const tls = options.cert !== undefined && options.key !== undefined
362
+ const http = tls ? await import('node:https') : await import('node:http')
363
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
364
+ const localGuard = {
365
+ host,
366
+ addresses: await interfaceAddresses(),
367
+ ...(Array.isArray(options.allowHosts) ? { allowHosts: options.allowHosts } : {}),
368
+ ...(Array.isArray(options.allowOrigins) ? { allowOrigins: options.allowOrigins } : {})
369
+ }
370
+ const dav = await openWebdav({ ...options, tls, host, localGuard })
255
371
  const onError = typeof options.onError === 'function'
256
372
  ? options.onError
257
373
  : (error) => { console.error(`webdav-serve: ${error.message}`) }
258
- const server = http.createServer(async (request, response) => {
374
+ const handler = async (request, response) => {
259
375
  let answer
260
376
  try {
261
377
  const body = await readBody(request)
262
- answer = await dav.step({ method: request.method, target: request.url, headers: headerPairs(request.rawHeaders), body })
378
+ answer = await dav.step({
379
+ method: request.method,
380
+ target: request.url,
381
+ headers: headerPairs(request.rawHeaders),
382
+ body,
383
+ remoteAddress: request.socket.remoteAddress
384
+ })
263
385
  } catch (error) {
264
386
  onError(error)
265
387
  response.writeHead(500, { 'content-type': 'text/plain' })
@@ -269,20 +391,27 @@ export async function listen (options = {}) {
269
391
  writeHeaders(response, answer.headers)
270
392
  response.statusCode = answer.status
271
393
  response.end(answer.body)
272
- })
394
+ }
395
+ const server = tls ? http.createServer({ cert: options.cert, key: options.key }, handler) : http.createServer(handler)
273
396
  const port = typeof options.port === 'number' ? options.port : 8090
274
- const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
275
- await new Promise((resolve, reject) => {
276
- server.once('error', reject)
277
- server.listen(port, host, resolve)
278
- })
397
+ try {
398
+ await new Promise((resolve, reject) => {
399
+ server.once('error', reject)
400
+ server.listen(port, host, resolve)
401
+ })
402
+ } catch (error) {
403
+ dav.close()
404
+ throw error
405
+ }
279
406
  const bound = server.address()
280
- const origin = `http://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
407
+ const origin = `${tls ? 'https' : 'http'}://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
281
408
  return {
282
409
  server,
283
410
  port: bound.port,
284
411
  origin,
285
412
  mounts: dav.mounts,
413
+ warnings: dav.warnings,
414
+ storageNotices: dav.storageNotices,
286
415
  close () {
287
416
  return new Promise((resolve) => {
288
417
  server.close(() => { dav.close(); resolve() })
@@ -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
+ }