@factoidal/core 0.28.0 → 0.30.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/server.mjs CHANGED
@@ -12,6 +12,11 @@
12
12
  // (`formal/lean4/Harness/XmppServe.lean`) does with files:
13
13
  //
14
14
  // * one engine session per TCP (or direct-TLS, XEP-0368) connection;
15
+ // * `startTls` → the engine sent `<proceed/>` (RFC 6120 section 5.4.2.3):
16
+ // stop reading in clear, run the TLS handshake on the same socket as
17
+ // the server, and call `xmppSessionTlsUp` when it finishes. The
18
+ // engine has already discarded every byte that arrived after
19
+ // `<starttls/>`; this host also drops its UTF-8 decoder's tail;
15
20
  // * `route` → find the sessions bound to that bare JID and call
16
21
  // `xmppSessionDeliver` on each; none bound → hold the stanza in
17
22
  // memory until one binds (`xmppSessionDeliver` itself refuses an
@@ -28,9 +33,8 @@
28
33
  // `xmppSessionDeliver`, `xmppSessionClose`, `xmppRosterGet`,
29
34
  // `xmppRosterSet`, `xmppClose`) is stated in the Lean module's header.
30
35
  //
31
- // Not implemented, said here rather than left to be discovered: STARTTLS
32
- // (RFC 6120 section 5 — give `cert` and `key` for direct TLS on 5223
33
- // instead), server-to-server federation, and any limit on how many bytes
36
+ // Not implemented, said here rather than left to be discovered:
37
+ // server-to-server federation, and any limit on how many bytes
34
38
  // a client may send without completing a unit beyond `maxBuffered`,
35
39
  // which is a transport limit this host applies by closing the socket.
36
40
 
@@ -39,12 +43,13 @@ import { StringDecoder } from 'node:string_decoder'
39
43
  import { mkdir, readFile, writeFile, readdir, unlink, rename } from 'node:fs/promises'
40
44
  import { join } from 'node:path'
41
45
  import { loadEngine } from '../bin/engine.mjs'
46
+ import { createXmppRouter } from './router.mjs'
42
47
 
43
48
  /** The ops this host needs from the engine. */
44
49
  export const XMPP_SERVER_OPS = [
45
50
  'xmppOpen', 'xmppClose',
46
51
  'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionClose',
47
- 'xmppRosterGet', 'xmppRosterSet'
52
+ 'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad'
48
53
  ]
49
54
 
50
55
  /** An error raised by this host, as opposed to one the engine reported.
@@ -128,6 +133,15 @@ export async function openXmppServer (options = {}) {
128
133
  if (typeof options.saltB64 === 'string') config.saltB64 = options.saltB64
129
134
  if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
130
135
  if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
136
+ // XEP-0045 room service (L4Factoidal/XMPP/Muc.lean); off unless named.
137
+ if (typeof options.mucDomain === 'string') config.mucDomain = options.mucDomain
138
+ // XEP-0369 channels (L4Factoidal/XMPP/Mix.lean), joined through MIX-PAM; off unless named.
139
+ if (typeof options.mixDomain === 'string') config.mixDomain = options.mixDomain
140
+ // GC3 prototype groups (L4Factoidal/XMPP/Gc3.lean; no published spec); off unless named.
141
+ if (typeof options.gc3Domain === 'string') config.gc3Domain = options.gc3Domain
142
+ // The host keeps the state across restarts (a state key and a DIR): only
143
+ // then does the engine produce `store`/`unstore`.
144
+ if (options.persist === true) config.persist = true
131
145
  let opened
132
146
  try {
133
147
  opened = engine.call('xmppOpen', [JSON.stringify(config)])
@@ -150,7 +164,7 @@ export async function openXmppServer (options = {}) {
150
164
  accounts: typeof opened.accounts === 'number' ? opened.accounts : undefined,
151
165
  /**
152
166
  * One client connection.
153
- * @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, maxUnits?: number}} [sessionOptions]
167
+ * @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, startTls?: boolean, maxUnits?: number}} [sessionOptions]
154
168
  */
155
169
  openSession (sessionOptions = {}) {
156
170
  const record = {
@@ -158,6 +172,7 @@ export async function openXmppServer (options = {}) {
158
172
  nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken()
159
173
  }
160
174
  if (typeof sessionOptions.tlsCarrier === 'boolean') record.tlsCarrier = sessionOptions.tlsCarrier
175
+ if (typeof sessionOptions.startTls === 'boolean') record.startTls = sessionOptions.startTls
161
176
  if (typeof sessionOptions.maxUnits === 'number') record.maxUnits = sessionOptions.maxUnits
162
177
  const answer = engine.call('xmppSessionOpen', [handle, JSON.stringify(record)])
163
178
  if (typeof answer.handle !== 'string') throw new XmppHostError('xmppSessionOpen answered with no handle')
@@ -169,18 +184,22 @@ export async function openXmppServer (options = {}) {
169
184
  feed (text) {
170
185
  return engine.call('xmppSessionFeed', [session, text])
171
186
  },
187
+ /** The TLS handshake a feed asked for (`startTls: true`) finished. */
188
+ tlsUp () {
189
+ return engine.call('xmppSessionTlsUp', [session])
190
+ },
172
191
  /** Hand this session a stanza another session routed to it. */
173
192
  deliver (text) {
174
193
  return engine.call('xmppSessionDeliver', [session, text])
175
194
  },
176
195
  /** End of input: returns `{send: string[]}` and releases the handle. */
177
196
  close () {
178
- if (!open) return { send: [] }
197
+ if (!open) return { send: [], routeFull: [] }
179
198
  open = false
180
199
  try {
181
200
  return engine.call('xmppSessionClose', [session])
182
201
  } catch (_error) {
183
- return { send: [] }
202
+ return { send: [], routeFull: [] }
184
203
  }
185
204
  }
186
205
  }
@@ -221,6 +240,9 @@ async function readAccountsFile (path) {
221
240
  * polled for stanzas written by other processes
222
241
  * @param {string|Buffer} [options.cert] PEM certificate chain for direct TLS
223
242
  * @param {string|Buffer} [options.key] PEM private key for direct TLS
243
+ * @param {boolean} [options.startTls] listen in clear and use `cert` and
244
+ * `key` for STARTTLS (RFC 6120 section 5), which the engine then requires
245
+ * before anything else; without it, `cert` and `key` mean direct TLS
224
246
  * @param {number} [options.pollMs] spool poll interval (default 250; 0 disables)
225
247
  * @param {number} [options.maxBuffered] residual UTF-8 bytes held by the engine
226
248
  * after a feed before the socket is closed (default 1 MiB); the decoder
@@ -249,8 +271,14 @@ export async function createXmppServer (options = {}) {
249
271
  const text = await readAccountsFile(join(dir, 'accounts'))
250
272
  if (text !== null) serverOptions.accountsText = text
251
273
  }
252
- const useTls = options.cert !== undefined && options.key !== undefined
274
+ const haveKey = options.cert !== undefined && options.key !== undefined
275
+ if (options.startTls === true && !haveKey) {
276
+ throw new XmppHostError('STARTTLS requires both cert and key')
277
+ }
278
+ const startTls = options.startTls === true
279
+ const useTls = haveKey && !startTls
253
280
  if (typeof serverOptions.tlsCarrier !== 'boolean') serverOptions.tlsCarrier = useTls
281
+ if (dir !== null && options.stateKey !== undefined) serverOptions.persist = true
254
282
  const xmpp = await openXmppServer(serverOptions)
255
283
 
256
284
  // Rosters the previous run persisted.
@@ -273,55 +301,99 @@ export async function createXmppServer (options = {}) {
273
301
  throw error
274
302
  }
275
303
 
276
- /** Every live connection: socket, engine session, and the last `bare`/`full` the engine reported. */
277
- const connections = new Set()
304
+ // The server's state across a restart (issue 1237): rosters, stanzas held
305
+ // for offline accounts, MIX channels and GC3 groups, as resources under
306
+ // /xmpp/ of an LWS storage kept in DIR/state, sealed under the state key,
307
+ // opened IN-PROCESS as the component principal (issue 1238: no token, no
308
+ // listener, Web Access Control decides). The engine says what to write
309
+ // (`store`, `unstore`); this host only performs it. A wrong key or an item
310
+ // that does not decode stops the start: it never starts empty.
311
+ let state = null
312
+ let stateWrites = Promise.resolve()
313
+ let stateError = null
314
+ if (dir !== null && options.stateKey !== undefined) {
315
+ try {
316
+ const { openLwsStorage } = await import('../lws/server.mjs')
317
+ const base = 'https://xmpp-state.invalid/'
318
+ const principal = 'urn:factoidal:component:xmpp-serve'
319
+ state = await openLwsStorage({
320
+ engine: xmpp.engine, baseIri: base, dir: join(dir, 'state'), storage: 'files',
321
+ storageKey: options.stateKey, principal, realClock: true, lockCommand: 'xmpp-serve',
322
+ // Absolute IRIs, which never depended on the base (relative ones work
323
+ // since issue https://github.com/danbri/factoidal/issues/1241 was fixed).
324
+ rootAcl: `@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n<${base}.acl#xmpp> a acl:Authorization ;\n` +
325
+ ` acl:agent <${principal}> ;\n acl:accessTo <${base}> ;\n acl:default <${base}> ;\n` +
326
+ ' acl:mode acl:Read, acl:Write, acl:Control .\n'
327
+ })
328
+ const get = (target, accept) => state.step({ method: 'GET', target, headers: accept ? [['Accept', accept]] : [], body: new Uint8Array(0) })
329
+ for (const kind of ['roster', 'held', 'mix', 'gc3']) {
330
+ const listing = await get(`/xmpp/${kind}/`, 'application/lws+json')
331
+ if (listing.status === 404) continue
332
+ if (listing.status !== 200) throw new XmppHostError(`state: listing /xmpp/${kind}/ answered ${listing.status}`)
333
+ const doc = JSON.parse(new TextDecoder().decode(listing.body))
334
+ for (const item of doc.items || []) {
335
+ const path = '/' + String(item.id).slice(base.length)
336
+ const got = await get(path)
337
+ if (got.status !== 200) throw new XmppHostError(`state: ${path} answered ${got.status}`)
338
+ const loaded = xmpp.engine.call('xmppStateLoad', [xmpp.handle, path, new TextDecoder().decode(got.body)])
339
+ if (loaded === null || typeof loaded !== 'object') throw new XmppHostError(`state: ${path} was refused`)
340
+ }
341
+ }
342
+ } catch (error) {
343
+ try { state?.close() } catch (_error) { /* already closed */ }
344
+ xmpp.close()
345
+ throw error instanceof XmppHostError ? error : new XmppHostError(`state: ${error.message}`)
346
+ }
347
+ }
348
+ const hexOf = (s) => Buffer.from(s, 'utf8').toString('hex')
349
+ /** Perform the engine's `store` / `unstore`, in order, one at a time. */
350
+ function persistState (answer) {
351
+ if (state === null) return
352
+ const stores = Array.isArray(answer.store) ? answer.store : []
353
+ const unstores = Array.isArray(answer.unstore) ? answer.unstore : []
354
+ if (stores.length === 0 && unstores.length === 0) return
355
+ stateWrites = stateWrites.then(async () => {
356
+ for (const { path, text } of stores) {
357
+ const r = await state.step({ method: 'PUT', target: path, headers: [['Content-Type', 'text/plain; charset=utf-8']], body: new TextEncoder().encode(text) })
358
+ if (r.status >= 300) throw new XmppHostError(`state: PUT ${path} answered ${r.status}`)
359
+ }
360
+ for (const path of unstores) {
361
+ const r = await state.step({ method: 'DELETE', target: path, headers: [], body: new Uint8Array(0) })
362
+ if (r.status >= 300 && r.status !== 404) throw new XmppHostError(`state: DELETE ${path} answered ${r.status}`)
363
+ }
364
+ }).catch((error) => { stateError = error; onError(error) })
365
+ }
366
+ // Migration: plaintext roster files of an earlier run go into the sealed
367
+ // state, then the plaintext files are deleted.
368
+ if (state !== null) {
369
+ for (const name of await readdir(join(dir, 'roster')).catch(() => [])) {
370
+ if (!name.endsWith('.tsv')) continue
371
+ const bare = decodeURIComponent(name.slice(0, -4))
372
+ const { encoded } = xmpp.roster(bare)
373
+ persistState({ store: [{ path: `/xmpp/roster/${hexOf(bare)}`, text: encoded }] })
374
+ await stateWrites
375
+ if (stateError === null) await unlink(join(dir, 'roster', name)).catch(() => {})
376
+ }
377
+ }
378
+
379
+ // The routing table (router.mjs, shared with the in-page host): every live
380
+ // connection, with the last `bare`/`full` the engine reported, and the
381
+ // bounded fallback queue for stanzas routed to a session that closed.
382
+ const router = createXmppRouter({ onError: (error) => onError(new XmppHostError(error.message)) })
383
+ const { connections, pending, boundTo, routeFull } = router
278
384
  // Raw transports also include TLS peers that have not reached the secure
279
385
  // connection callback and therefore have no engine session yet.
280
386
  const transports = new Set()
281
- /** bare JID → stanzas routed to it while no session was bound. */
282
- const pending = new Map()
283
-
284
- function boundTo (bare) {
285
- const found = []
286
- for (const c of connections) if (c.full !== null && c.bare === bare) found.push(c)
287
- return found
288
- }
289
387
 
290
388
  function writeAll (c, lines) {
291
389
  for (const line of lines) if (!c.socket.destroyed) c.socket.write(line)
292
390
  }
293
391
 
294
- /** Deliver one stanza to every session bound to `bare`, or hold it. */
295
- function routeStanza (bare, text) {
296
- const targets = boundTo(bare)
297
- if (targets.length === 0) {
298
- if (!pending.has(bare)) pending.set(bare, [])
299
- pending.get(bare).push(text)
300
- return
301
- }
302
- for (const t of targets) {
303
- let answer
304
- try { answer = t.session.deliver(text) } catch (error) { onError(error); continue }
305
- if (answer.delivered === true) writeAll(t, answer.send)
306
- else {
307
- if (!pending.has(bare)) pending.set(bare, [])
308
- pending.get(bare).push(text)
309
- }
310
- }
311
- }
312
-
313
- /** A session just bound: hand it what was waiting. */
314
- function flushPending (c) {
315
- const queue = pending.get(c.bare)
316
- if (!queue || queue.length === 0) return
317
- pending.delete(c.bare)
318
- for (const text of queue) routeStanza(c.bare, text)
319
- }
320
-
321
392
  let rosterWrites = Promise.resolve()
322
393
  let persistenceError = null
323
394
  function persistRosters (bares) {
324
- if (dir === null) return
395
+ // With a state key the rosters are in the sealed state (`persistState`).
396
+ if (dir === null || state !== null) return
325
397
  for (const bare of bares) {
326
398
  try {
327
399
  // Capture the engine's bytes at this feed, before another feed can
@@ -349,28 +421,63 @@ export async function createXmppServer (options = {}) {
349
421
 
350
422
  /** Perform one feed answer. */
351
423
  function perform (c, answer) {
424
+ persistState(answer)
352
425
  writeAll(c, answer.send)
353
- const wasBound = c.full !== null
354
- c.bare = typeof answer.bare === 'string' ? answer.bare : null
355
- c.full = typeof answer.full === 'string' ? answer.full : null
356
- for (const r of answer.route) routeStanza(r.to, r.text)
426
+ router.apply(c, answer)
357
427
  if (Array.isArray(answer.rostersSaved) && answer.rostersSaved.length > 0) {
358
428
  persistRosters(answer.rostersSaved)
359
429
  }
360
- if (!wasBound && c.full !== null) flushPending(c)
430
+ if (answer.startTls === true && answer.close !== true) upgrade(c)
361
431
  if (answer.close === true) {
362
432
  c.closing = true
363
433
  c.socket.end()
364
434
  }
365
435
  }
366
436
 
437
+ /** RFC 6120 section 5.4.3: the `<proceed/>` is already written. Stop
438
+ * reading in clear and become the TLS server on the same socket. */
439
+ function upgrade (c) {
440
+ c.upgrading = true
441
+ const raw = c.socket
442
+ raw.removeListener('data', c.onData)
443
+ import('node:tls').then((tls) => {
444
+ const secure = new tls.TLSSocket(raw, {
445
+ isServer: true,
446
+ secureContext: tls.createSecureContext({ cert: options.cert, key: options.key })
447
+ })
448
+ secure.once('secure', () => {
449
+ let answer
450
+ try { answer = c.session.tlsUp() } catch (error) { onError(error); answer = null }
451
+ if (answer === null || answer.ok === false) {
452
+ onError(new XmppHostError(`xmppSessionTlsUp refused: ${answer && answer.error}`))
453
+ secure.destroy()
454
+ endSession(c)
455
+ return
456
+ }
457
+ c.socket = secure
458
+ c.decoder = new StringDecoder('utf8')
459
+ c.upgrading = false
460
+ secure.on('data', c.onData)
461
+ })
462
+ secure.on('error', (error) => {
463
+ onError(new XmppHostError(`STARTTLS handshake: ${error.message}`))
464
+ secure.destroy()
465
+ endSession(c)
466
+ })
467
+ secure.on('close', () => endSession(c))
468
+ }).catch((error) => { onError(error); raw.destroy(); endSession(c) })
469
+ }
470
+
367
471
  function endSession (c) {
368
472
  if (!connections.has(c)) return
369
473
  connections.delete(c)
370
474
  let answer
371
475
  try { answer = c.session.close() } catch (error) { onError(error); answer = { send: [] } }
476
+ // The session left its MUC rooms (Muc.leaveAll): tell the other occupants.
477
+ for (const r of answer.routeFull || []) routeFull(c, r.to, r.text, null)
372
478
  if (!c.closing && !c.socket.destroyed) {
373
479
  writeAll(c, answer.send)
480
+ if (answer.bytes instanceof Uint8Array && answer.bytes.length > 0) c.socket.write(answer.bytes)
374
481
  c.socket.end()
375
482
  }
376
483
  }
@@ -378,7 +485,7 @@ export async function createXmppServer (options = {}) {
378
485
  function onConnection (socket) {
379
486
  let session
380
487
  try {
381
- session = xmpp.openSession({ tlsCarrier: useTls })
488
+ session = xmpp.openSession({ tlsCarrier: useTls, startTls })
382
489
  } catch (error) {
383
490
  onError(error)
384
491
  socket.destroy()
@@ -390,12 +497,17 @@ export async function createXmppServer (options = {}) {
390
497
  bare: null,
391
498
  full: null,
392
499
  closing: false,
393
- decoder: new StringDecoder('utf8')
500
+ decoder: new StringDecoder('utf8'),
501
+ deliver (text) {
502
+ const answer = c.session.deliver(text)
503
+ if (answer.delivered === true) { writeAll(c, answer.send); return true }
504
+ return false
505
+ }
394
506
  }
395
507
  connections.add(c)
396
508
  onLog(`connection from ${socket.remoteAddress}:${socket.remotePort}`)
397
- socket.on('data', (chunk) => {
398
- if (!connections.has(c) || c.closing) return
509
+ c.onData = (chunk) => {
510
+ if (!connections.has(c) || c.closing || c.upgrading) return
399
511
  const text = c.decoder.write(chunk)
400
512
  if (text.length === 0) return
401
513
  let answer
@@ -418,6 +530,78 @@ export async function createXmppServer (options = {}) {
418
530
  socket.destroy()
419
531
  endSession(c)
420
532
  }
533
+ }
534
+ socket.on('data', c.onData)
535
+ socket.on('end', () => endSession(c))
536
+ socket.on('close', () => endSession(c))
537
+ socket.on('error', (error) => {
538
+ if (error && error.code !== 'ECONNRESET') onError(error)
539
+ endSession(c)
540
+ })
541
+ }
542
+
543
+ // XMPP over WebSocket (RFC 7395): every byte goes to xmppWsFeed, which
544
+ // does the HTTP handshake, the frames and Server.step; this host writes
545
+ // the bytes it is given and performs route/presence/rostersSaved as for TCP.
546
+ const ws = options.websocket && typeof options.websocket === 'object' ? options.websocket : null
547
+ function wsCall (op, args, bytes) {
548
+ const got = xmpp.engine.callBlobIO(op, args, bytes)
549
+ return { envelope: got.envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
550
+ }
551
+ function onWsConnection (socket) {
552
+ let handle
553
+ try {
554
+ const opened = xmpp.engine.call('xmppWsOpen', [xmpp.handle, JSON.stringify({
555
+ streamId: randomToken(), nonce: randomToken(),
556
+ path: typeof ws.path === 'string' ? ws.path : '/xmpp-websocket',
557
+ allowedOrigins: Array.isArray(ws.allowedOrigins) ? ws.allowedOrigins : [],
558
+ allowNoOrigin: ws.allowNoOrigin === true,
559
+ tlsCarrier: ws.cert !== undefined && ws.key !== undefined
560
+ })])
561
+ handle = opened.handle
562
+ } catch (error) {
563
+ onError(error)
564
+ socket.destroy()
565
+ return
566
+ }
567
+ const c = {
568
+ socket,
569
+ bare: null,
570
+ full: null,
571
+ closing: false,
572
+ session: {
573
+ close () {
574
+ try {
575
+ const got = wsCall('xmppWsClose', [handle], new Uint8Array(0))
576
+ return { send: [], bytes: got.bytes, routeFull: got.envelope.routeFull || [] }
577
+ } catch (_error) { return { send: [] } }
578
+ }
579
+ },
580
+ deliver (text) {
581
+ const got = wsCall('xmppWsDeliver', [handle, text], new Uint8Array(0))
582
+ if (got.envelope.delivered === true) { if (!socket.destroyed) socket.write(got.bytes); return true }
583
+ return false
584
+ }
585
+ }
586
+ connections.add(c)
587
+ socket.on('data', (chunk) => {
588
+ if (!connections.has(c) || c.closing) return
589
+ let got
590
+ try {
591
+ got = wsCall('xmppWsFeed', [handle], new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
592
+ } catch (error) {
593
+ onError(error)
594
+ socket.destroy()
595
+ endSession(c)
596
+ return
597
+ }
598
+ if (got.bytes.length > 0 && !socket.destroyed) socket.write(got.bytes)
599
+ perform(c, { ...got.envelope, send: [] })
600
+ if (got.envelope.bufferedBytes > maxBuffered) {
601
+ onError(new XmppHostError(`connection exceeded ${maxBuffered} buffered bytes`))
602
+ socket.destroy()
603
+ endSession(c)
604
+ }
421
605
  })
422
606
  socket.on('end', () => endSession(c))
423
607
  socket.on('close', () => endSession(c))
@@ -426,6 +610,24 @@ export async function createXmppServer (options = {}) {
426
610
  endSession(c)
427
611
  })
428
612
  }
613
+ let wsServer = null
614
+ if (ws !== null) {
615
+ if ((ws.cert !== undefined) !== (ws.key !== undefined)) {
616
+ xmpp.close()
617
+ throw new XmppHostError('the WebSocket listener needs both cert and key for TLS')
618
+ }
619
+ if (ws.cert !== undefined) {
620
+ const tls = await import('node:tls')
621
+ wsServer = tls.createServer({ cert: ws.cert, key: ws.key }, onWsConnection)
622
+ } else {
623
+ const net = await import('node:net')
624
+ wsServer = net.createServer(onWsConnection)
625
+ }
626
+ wsServer.on('connection', socket => {
627
+ transports.add(socket)
628
+ socket.once('close', () => transports.delete(socket))
629
+ })
630
+ }
429
631
 
430
632
  let server
431
633
  try {
@@ -470,8 +672,7 @@ export async function createXmppServer (options = {}) {
470
672
  let accepted = false
471
673
  for (const t of targets) {
472
674
  try {
473
- const answer = t.session.deliver(text)
474
- if (answer.delivered === true) { writeAll(t, answer.send); accepted = true }
675
+ if (t.deliver(text)) accepted = true
475
676
  } catch (error) { onError(error) }
476
677
  }
477
678
  if (accepted) await unlink(path).catch(() => {})
@@ -491,6 +692,7 @@ export async function createXmppServer (options = {}) {
491
692
  let closePromise = null
492
693
  return {
493
694
  server,
695
+ wsServer,
494
696
  xmpp,
495
697
  /** The live connections' `{bare, full}` pairs, for a host's own view. */
496
698
  sessions () {
@@ -511,10 +713,14 @@ export async function createXmppServer (options = {}) {
511
713
  for (const socket of transports) socket.destroy()
512
714
  try {
513
715
  await new Promise(resolve => server.close(resolve))
716
+ if (wsServer !== null) await new Promise(resolve => wsServer.close(resolve))
514
717
  await pollTask
515
718
  await rosterWrites
719
+ await stateWrites
516
720
  if (persistenceError !== null) throw persistenceError
721
+ if (stateError !== null) throw stateError
517
722
  } finally {
723
+ try { state?.close() } catch (_error) { /* already closed */ }
518
724
  xmpp.close()
519
725
  }
520
726
  })()
@@ -543,7 +749,23 @@ export async function listen (options = {}) {
543
749
  await created.close()
544
750
  throw error
545
751
  }
752
+ let wsOrigin = null
753
+ if (created.wsServer !== null) {
754
+ const wsPort = typeof options.websocket.port === 'number' ? options.websocket.port : 0
755
+ try {
756
+ await new Promise((resolve, reject) => {
757
+ created.wsServer.once('error', reject)
758
+ created.wsServer.listen(wsPort, host, resolve)
759
+ })
760
+ } catch (error) {
761
+ await created.close()
762
+ throw error
763
+ }
764
+ const wsScheme = options.websocket.cert !== undefined ? 'wss' : 'ws'
765
+ const path = typeof options.websocket.path === 'string' ? options.websocket.path : '/xmpp-websocket'
766
+ wsOrigin = `${wsScheme}://${host}:${created.wsServer.address().port}${path}`
767
+ }
546
768
  const bound = created.server.address()
547
769
  const scheme = options.cert !== undefined && options.key !== undefined ? 'xmpps' : 'xmpp'
548
- return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}` }
770
+ return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}`, wsOrigin }
549
771
  }