@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/CHANGELOG.md +27 -1
- package/README.md +5 -4
- package/bin/factoidal.mjs +113 -23
- package/browser.d.ts +2 -0
- package/browser.js +2 -0
- package/index.d.ts +46 -0
- package/index.mjs +2 -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 +134 -0
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +4 -3
- package/lws/dataset.mjs +160 -0
- package/lws/inpage.mjs +135 -0
- package/lws/persist.mjs +1 -1
- package/lws/server.mjs +55 -9
- package/package.json +9 -2
- package/xmpp/README.md +9 -1
- package/xmpp/inpage.mjs +197 -0
- package/xmpp/router.mjs +96 -0
- package/xmpp/server.mjs +280 -58
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:
|
|
32
|
-
//
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|