@factoidal/core 0.12.0 → 0.13.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 +183 -0
- package/README.md +13 -1
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +372 -6
- package/index.d.ts +17 -7
- 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 +3 -2
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +17 -1
- package/lws/server.mjs +25 -3
- package/package.json +9 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
package/xmpp/server.mjs
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// factoidal/xmpp/server.mjs — a Node host for the Lean XMPP server
|
|
2
|
+
// (`formal/lean4/L4Factoidal/XMPP/`), through the wasm session ops of
|
|
3
|
+
// `formal/lean4/Wasm/Ops/XmppSession.lean`.
|
|
4
|
+
//
|
|
5
|
+
// This file is a socket, a UTF-8 decoder and a routing table. Every
|
|
6
|
+
// protocol decision — what answers a stream header, whether a password
|
|
7
|
+
// is right, where a stanza goes, what a roster looks like as bytes —
|
|
8
|
+
// is made by `L4Factoidal.XMPP.Server.step` inside the engine and
|
|
9
|
+
// reaches this file as the `send`/`route`/`presence`/`close` members of
|
|
10
|
+
// one `xmppSessionFeed` answer. Iron rule 7 of `CLAUDE.md`: no protocol
|
|
11
|
+
// logic in a host. What this host does instead, and the native harness
|
|
12
|
+
// (`formal/lean4/Harness/XmppServe.lean`) does with files:
|
|
13
|
+
//
|
|
14
|
+
// * one engine session per TCP (or direct-TLS, XEP-0368) connection;
|
|
15
|
+
// * `route` → find the sessions bound to that bare JID and call
|
|
16
|
+
// `xmppSessionDeliver` on each; none bound → hold the stanza in
|
|
17
|
+
// memory until one binds (`xmppSessionDeliver` itself refuses an
|
|
18
|
+
// unbound session, so nothing is destroyed on the way);
|
|
19
|
+
// * `rostersSaved` → fetch the roster bytes with `xmppRosterGet` and
|
|
20
|
+
// write them under DIR/roster/, unread, so a restart gets them back
|
|
21
|
+
// through `xmppRosterSet`;
|
|
22
|
+
// * DIR/spool/<bare jid>/<file> → the harness's mailbox layout, polled
|
|
23
|
+
// for bound sessions and delivered, so a process that is not an XMPP
|
|
24
|
+
// client (a web application, say) can hand a stanza to a user by
|
|
25
|
+
// writing a file, exactly as it can with the native carrier.
|
|
26
|
+
//
|
|
27
|
+
// The wasm contract (`xmppOpen`, `xmppSessionOpen`, `xmppSessionFeed`,
|
|
28
|
+
// `xmppSessionDeliver`, `xmppSessionClose`, `xmppRosterGet`,
|
|
29
|
+
// `xmppRosterSet`, `xmppClose`) is stated in the Lean module's header.
|
|
30
|
+
//
|
|
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
|
|
34
|
+
// a client may send without completing a unit beyond `maxBuffered`,
|
|
35
|
+
// which is a transport limit this host applies by closing the socket.
|
|
36
|
+
|
|
37
|
+
import { randomBytes } from 'node:crypto'
|
|
38
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
39
|
+
import { mkdir, readFile, writeFile, readdir, unlink, rename } from 'node:fs/promises'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
42
|
+
|
|
43
|
+
/** The ops this host needs from the engine. */
|
|
44
|
+
export const XMPP_SERVER_OPS = [
|
|
45
|
+
'xmppOpen', 'xmppClose',
|
|
46
|
+
'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionClose',
|
|
47
|
+
'xmppRosterGet', 'xmppRosterSet'
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** An error raised by this host, as opposed to one the engine reported.
|
|
51
|
+
* `unknownOp` is set when the loaded WebAssembly module does not carry
|
|
52
|
+
* the operation at all, which is what a test skips on and what the CLI
|
|
53
|
+
* reports as "not yet wired". */
|
|
54
|
+
export class XmppHostError extends Error {
|
|
55
|
+
constructor (message, detail = {}) {
|
|
56
|
+
super(message)
|
|
57
|
+
this.name = 'XmppHostError'
|
|
58
|
+
this.unknownOp = detail.unknownOp === true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isUnknownOp (error) {
|
|
63
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
64
|
+
return text.indexOf('unknown op') >= 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the loaded engine carries the XMPP server operations.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
72
|
+
* @returns {Promise<{available: boolean, missing: string[]}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function xmppServerOpsAvailable (options = {}) {
|
|
75
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
76
|
+
let names
|
|
77
|
+
try {
|
|
78
|
+
names = engine.call('ops', []).ops
|
|
79
|
+
} catch (_error) {
|
|
80
|
+
return { available: false, missing: XMPP_SERVER_OPS.slice() }
|
|
81
|
+
}
|
|
82
|
+
const missing = XMPP_SERVER_OPS.filter((op) => !names.includes(op))
|
|
83
|
+
return { available: missing.length === 0, missing }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A bare JID as one path segment: the harness's `pathSafe`
|
|
87
|
+
* (`Harness/XmppServe.lean`), so the two hosts share a state directory.
|
|
88
|
+
* Every byte that is not a letter, a digit, `.`, `-` or `_` is written
|
|
89
|
+
* as `%` and two lowercase hex digits. */
|
|
90
|
+
export function pathSafe (s) {
|
|
91
|
+
let out = ''
|
|
92
|
+
for (const byte of Buffer.from(s, 'utf8')) {
|
|
93
|
+
const ch = String.fromCharCode(byte)
|
|
94
|
+
if (byte < 0x80 && /[A-Za-z0-9._-]/.test(ch)) out += ch
|
|
95
|
+
else out += '%' + byte.toString(16).padStart(2, '0')
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Unpredictable values for the stream id and the SCRAM server nonce
|
|
101
|
+
* (RFC 6120 section 4.7.3, RFC 5802 section 5.1). The engine has no
|
|
102
|
+
* random source and asks the host for both. */
|
|
103
|
+
function randomToken (bytes = 18) {
|
|
104
|
+
return randomBytes(bytes).toString('base64url')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Open one XMPP server on the engine and return the handle plus the
|
|
109
|
+
* calls a host makes against it.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} options
|
|
112
|
+
* @param {string} [options.domain] the domain the server is authoritative for (default `localhost`)
|
|
113
|
+
* @param {Array<{username: string, password: string}>} [options.accounts]
|
|
114
|
+
* @param {string} [options.accountsText] `user:password` lines, the harness's accounts-file format
|
|
115
|
+
* @param {string} [options.saltB64] the SCRAM salt, base64
|
|
116
|
+
* @param {number} [options.iterationCount] the SCRAM iteration count
|
|
117
|
+
* @param {boolean} [options.tlsCarrier] whether the carrier terminates TLS
|
|
118
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
119
|
+
* @returns {Promise<{engine: object, handle: string, domain: string,
|
|
120
|
+
* openSession: Function, roster: Function, setRoster: Function, close: Function}>}
|
|
121
|
+
*/
|
|
122
|
+
export async function openXmppServer (options = {}) {
|
|
123
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
124
|
+
const config = {}
|
|
125
|
+
if (typeof options.domain === 'string') config.domain = options.domain
|
|
126
|
+
if (Array.isArray(options.accounts)) config.accounts = options.accounts
|
|
127
|
+
if (typeof options.accountsText === 'string') config.accountsText = options.accountsText
|
|
128
|
+
if (typeof options.saltB64 === 'string') config.saltB64 = options.saltB64
|
|
129
|
+
if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
|
|
130
|
+
if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
|
|
131
|
+
let opened
|
|
132
|
+
try {
|
|
133
|
+
opened = engine.call('xmppOpen', [JSON.stringify(config)])
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (isUnknownOp(error)) {
|
|
136
|
+
throw new XmppHostError(
|
|
137
|
+
'the loaded l4factoidal WebAssembly module does not carry xmppOpen',
|
|
138
|
+
{ unknownOp: true })
|
|
139
|
+
}
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
if (typeof opened.handle !== 'string' || opened.handle.length === 0) {
|
|
143
|
+
throw new XmppHostError('xmppOpen answered with no handle')
|
|
144
|
+
}
|
|
145
|
+
const handle = opened.handle
|
|
146
|
+
return {
|
|
147
|
+
engine,
|
|
148
|
+
handle,
|
|
149
|
+
domain: typeof opened.domain === 'string' ? opened.domain : (config.domain || 'localhost'),
|
|
150
|
+
accounts: typeof opened.accounts === 'number' ? opened.accounts : undefined,
|
|
151
|
+
/**
|
|
152
|
+
* One client connection.
|
|
153
|
+
* @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, maxUnits?: number}} [sessionOptions]
|
|
154
|
+
*/
|
|
155
|
+
openSession (sessionOptions = {}) {
|
|
156
|
+
const record = {
|
|
157
|
+
streamId: typeof sessionOptions.streamId === 'string' ? sessionOptions.streamId : randomToken(),
|
|
158
|
+
nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken()
|
|
159
|
+
}
|
|
160
|
+
if (typeof sessionOptions.tlsCarrier === 'boolean') record.tlsCarrier = sessionOptions.tlsCarrier
|
|
161
|
+
if (typeof sessionOptions.maxUnits === 'number') record.maxUnits = sessionOptions.maxUnits
|
|
162
|
+
const answer = engine.call('xmppSessionOpen', [handle, JSON.stringify(record)])
|
|
163
|
+
if (typeof answer.handle !== 'string') throw new XmppHostError('xmppSessionOpen answered with no handle')
|
|
164
|
+
const session = answer.handle
|
|
165
|
+
let open = true
|
|
166
|
+
return {
|
|
167
|
+
handle: session,
|
|
168
|
+
/** Feed received text; returns the engine's answer document. */
|
|
169
|
+
feed (text) {
|
|
170
|
+
return engine.call('xmppSessionFeed', [session, text])
|
|
171
|
+
},
|
|
172
|
+
/** Hand this session a stanza another session routed to it. */
|
|
173
|
+
deliver (text) {
|
|
174
|
+
return engine.call('xmppSessionDeliver', [session, text])
|
|
175
|
+
},
|
|
176
|
+
/** End of input: returns `{send: string[]}` and releases the handle. */
|
|
177
|
+
close () {
|
|
178
|
+
if (!open) return { send: [] }
|
|
179
|
+
open = false
|
|
180
|
+
try {
|
|
181
|
+
return engine.call('xmppSessionClose', [session])
|
|
182
|
+
} catch (_error) {
|
|
183
|
+
return { send: [] }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
/** The roster of a bare JID: `{encoded, items}`. */
|
|
189
|
+
roster (bare) {
|
|
190
|
+
return engine.call('xmppRosterGet', [handle, bare])
|
|
191
|
+
},
|
|
192
|
+
/** Load a roster the host persisted (the `encoded` of `roster()`). */
|
|
193
|
+
setRoster (bare, encoded) {
|
|
194
|
+
engine.call('xmppRosterSet', [handle, bare, encoded])
|
|
195
|
+
},
|
|
196
|
+
close () {
|
|
197
|
+
try { engine.call('xmppClose', [handle]) } catch (_error) { /* already gone */ }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Load `DIR/accounts` (one `user:password` per line) if it exists. */
|
|
203
|
+
async function readAccountsFile (path) {
|
|
204
|
+
try {
|
|
205
|
+
return await readFile(path, 'utf8')
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error && error.code === 'ENOENT') return null
|
|
208
|
+
throw error
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A `net.Server` (or `tls.Server`, when `cert` and `key` are given —
|
|
214
|
+
* XEP-0368 direct TLS) that runs one engine session per connection.
|
|
215
|
+
*
|
|
216
|
+
* The server is returned unlistened: the caller chooses the port.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} options as `openXmppServer`, plus:
|
|
219
|
+
* @param {string} [options.dir] state directory: `accounts` is read from it when
|
|
220
|
+
* no accounts are given; rosters are persisted under `roster/`; `spool/` is
|
|
221
|
+
* polled for stanzas written by other processes
|
|
222
|
+
* @param {string|Buffer} [options.cert] PEM certificate chain for direct TLS
|
|
223
|
+
* @param {string|Buffer} [options.key] PEM private key for direct TLS
|
|
224
|
+
* @param {number} [options.pollMs] spool poll interval (default 250; 0 disables)
|
|
225
|
+
* @param {number} [options.maxBuffered] residual UTF-8 bytes held by the engine
|
|
226
|
+
* after a feed before the socket is closed (default 1 MiB); the decoder
|
|
227
|
+
* may separately hold up to three bytes of an incomplete UTF-8 sequence
|
|
228
|
+
* @param {Function} [options.onError]
|
|
229
|
+
* @param {Function} [options.onLog]
|
|
230
|
+
* @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
|
|
231
|
+
*/
|
|
232
|
+
export async function createXmppServer (options = {}) {
|
|
233
|
+
const onError = typeof options.onError === 'function'
|
|
234
|
+
? options.onError
|
|
235
|
+
: (error) => { console.error(`xmpp-serve: ${error.message}`) }
|
|
236
|
+
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
|
|
237
|
+
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
238
|
+
const pollMs = typeof options.pollMs === 'number' ? options.pollMs : 250
|
|
239
|
+
const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 1024 * 1024
|
|
240
|
+
if (!Number.isSafeInteger(maxBuffered) || maxBuffered < 0) {
|
|
241
|
+
throw new XmppHostError('maxBuffered must be a nonnegative safe integer')
|
|
242
|
+
}
|
|
243
|
+
if ((options.cert !== undefined) !== (options.key !== undefined)) {
|
|
244
|
+
throw new XmppHostError('direct TLS requires both cert and key')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const serverOptions = { ...options }
|
|
248
|
+
if (dir !== null && !Array.isArray(options.accounts) && typeof options.accountsText !== 'string') {
|
|
249
|
+
const text = await readAccountsFile(join(dir, 'accounts'))
|
|
250
|
+
if (text !== null) serverOptions.accountsText = text
|
|
251
|
+
}
|
|
252
|
+
const useTls = options.cert !== undefined && options.key !== undefined
|
|
253
|
+
if (typeof serverOptions.tlsCarrier !== 'boolean') serverOptions.tlsCarrier = useTls
|
|
254
|
+
const xmpp = await openXmppServer(serverOptions)
|
|
255
|
+
|
|
256
|
+
// Rosters the previous run persisted.
|
|
257
|
+
try {
|
|
258
|
+
if (dir !== null) {
|
|
259
|
+
await mkdir(join(dir, 'roster'), { recursive: true })
|
|
260
|
+
await mkdir(join(dir, 'spool'), { recursive: true })
|
|
261
|
+
for (const name of await readdir(join(dir, 'roster'))) {
|
|
262
|
+
if (!name.endsWith('.tsv')) continue
|
|
263
|
+
try {
|
|
264
|
+
const bare = decodeURIComponent(name.slice(0, -4))
|
|
265
|
+
xmpp.setRoster(bare, await readFile(join(dir, 'roster', name), 'utf8'))
|
|
266
|
+
} catch (error) {
|
|
267
|
+
onError(new XmppHostError(`roster ${name}: ${error.message}`))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
xmpp.close()
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Every live connection: socket, engine session, and the last `bare`/`full` the engine reported. */
|
|
277
|
+
const connections = new Set()
|
|
278
|
+
// Raw transports also include TLS peers that have not reached the secure
|
|
279
|
+
// connection callback and therefore have no engine session yet.
|
|
280
|
+
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
|
+
|
|
290
|
+
function writeAll (c, lines) {
|
|
291
|
+
for (const line of lines) if (!c.socket.destroyed) c.socket.write(line)
|
|
292
|
+
}
|
|
293
|
+
|
|
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
|
+
let rosterWrites = Promise.resolve()
|
|
322
|
+
let persistenceError = null
|
|
323
|
+
function persistRosters (bares) {
|
|
324
|
+
if (dir === null) return
|
|
325
|
+
for (const bare of bares) {
|
|
326
|
+
try {
|
|
327
|
+
// Capture the engine's bytes at this feed, before another feed can
|
|
328
|
+
// change the roster. Serialize writes; replace each file atomically.
|
|
329
|
+
const { encoded } = xmpp.roster(bare)
|
|
330
|
+
rosterWrites = rosterWrites.then(async () => {
|
|
331
|
+
const path = join(dir, 'roster', pathSafe(bare) + '.tsv')
|
|
332
|
+
const temporary = path + '.' + randomToken() + '.tmp'
|
|
333
|
+
try {
|
|
334
|
+
await writeFile(temporary, encoded, { flag: 'wx' })
|
|
335
|
+
await rename(temporary, path)
|
|
336
|
+
} finally {
|
|
337
|
+
await unlink(temporary).catch(() => {})
|
|
338
|
+
}
|
|
339
|
+
}).catch(error => {
|
|
340
|
+
persistenceError = new XmppHostError(`persisting roster of ${bare}: ${error.message}`)
|
|
341
|
+
onError(persistenceError)
|
|
342
|
+
})
|
|
343
|
+
} catch (error) {
|
|
344
|
+
persistenceError = new XmppHostError(`reading roster of ${bare}: ${error.message}`)
|
|
345
|
+
onError(persistenceError)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Perform one feed answer. */
|
|
351
|
+
function perform (c, answer) {
|
|
352
|
+
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)
|
|
357
|
+
if (Array.isArray(answer.rostersSaved) && answer.rostersSaved.length > 0) {
|
|
358
|
+
persistRosters(answer.rostersSaved)
|
|
359
|
+
}
|
|
360
|
+
if (!wasBound && c.full !== null) flushPending(c)
|
|
361
|
+
if (answer.close === true) {
|
|
362
|
+
c.closing = true
|
|
363
|
+
c.socket.end()
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function endSession (c) {
|
|
368
|
+
if (!connections.has(c)) return
|
|
369
|
+
connections.delete(c)
|
|
370
|
+
let answer
|
|
371
|
+
try { answer = c.session.close() } catch (error) { onError(error); answer = { send: [] } }
|
|
372
|
+
if (!c.closing && !c.socket.destroyed) {
|
|
373
|
+
writeAll(c, answer.send)
|
|
374
|
+
c.socket.end()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function onConnection (socket) {
|
|
379
|
+
let session
|
|
380
|
+
try {
|
|
381
|
+
session = xmpp.openSession({ tlsCarrier: useTls })
|
|
382
|
+
} catch (error) {
|
|
383
|
+
onError(error)
|
|
384
|
+
socket.destroy()
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const c = {
|
|
388
|
+
socket,
|
|
389
|
+
session,
|
|
390
|
+
bare: null,
|
|
391
|
+
full: null,
|
|
392
|
+
closing: false,
|
|
393
|
+
decoder: new StringDecoder('utf8')
|
|
394
|
+
}
|
|
395
|
+
connections.add(c)
|
|
396
|
+
onLog(`connection from ${socket.remoteAddress}:${socket.remotePort}`)
|
|
397
|
+
socket.on('data', (chunk) => {
|
|
398
|
+
if (!connections.has(c) || c.closing) return
|
|
399
|
+
const text = c.decoder.write(chunk)
|
|
400
|
+
if (text.length === 0) return
|
|
401
|
+
let answer
|
|
402
|
+
try {
|
|
403
|
+
answer = session.feed(text)
|
|
404
|
+
if (!Number.isSafeInteger(answer.bufferedBytes) || answer.bufferedBytes < 0) {
|
|
405
|
+
throw new XmppHostError('xmppSessionFeed lacks a valid bufferedBytes result; rebuild the engine')
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
onError(error)
|
|
409
|
+
socket.destroy()
|
|
410
|
+
endSession(c)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
// A transport limit, not a protocol one: a client that sends this
|
|
414
|
+
// much without completing a unit is cut off.
|
|
415
|
+
perform(c, answer)
|
|
416
|
+
if (answer.bufferedBytes > maxBuffered) {
|
|
417
|
+
onError(new XmppHostError(`connection exceeded ${maxBuffered} buffered bytes`))
|
|
418
|
+
socket.destroy()
|
|
419
|
+
endSession(c)
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
socket.on('end', () => endSession(c))
|
|
423
|
+
socket.on('close', () => endSession(c))
|
|
424
|
+
socket.on('error', (error) => {
|
|
425
|
+
if (error && error.code !== 'ECONNRESET') onError(error)
|
|
426
|
+
endSession(c)
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let server
|
|
431
|
+
try {
|
|
432
|
+
if (useTls) {
|
|
433
|
+
const tls = await import('node:tls')
|
|
434
|
+
server = tls.createServer({ cert: options.cert, key: options.key }, onConnection)
|
|
435
|
+
} else {
|
|
436
|
+
const net = await import('node:net')
|
|
437
|
+
server = net.createServer(onConnection)
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
xmpp.close()
|
|
441
|
+
throw error
|
|
442
|
+
}
|
|
443
|
+
server.on('connection', socket => {
|
|
444
|
+
transports.add(socket)
|
|
445
|
+
socket.once('close', () => transports.delete(socket))
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// The harness's mailbox: `<dir>/spool/<bare jid>/<name>` holds one
|
|
449
|
+
// stanza's bytes for that account. Polled for every bound session,
|
|
450
|
+
// delivered in name order, unlinked once the engine accepted it.
|
|
451
|
+
let poller = null
|
|
452
|
+
let polling = false
|
|
453
|
+
let pollTask = Promise.resolve()
|
|
454
|
+
async function pollSpool () {
|
|
455
|
+
if (polling || dir === null) return
|
|
456
|
+
polling = true
|
|
457
|
+
try {
|
|
458
|
+
const bares = new Set()
|
|
459
|
+
for (const c of connections) if (c.full !== null) bares.add(c.bare)
|
|
460
|
+
for (const bare of bares) {
|
|
461
|
+
const box = join(dir, 'spool', pathSafe(bare))
|
|
462
|
+
let names
|
|
463
|
+
try { names = (await readdir(box)).sort() } catch (_error) { continue }
|
|
464
|
+
for (const name of names) {
|
|
465
|
+
const path = join(box, name)
|
|
466
|
+
let text
|
|
467
|
+
try { text = await readFile(path, 'utf8') } catch (_error) { continue }
|
|
468
|
+
const targets = boundTo(bare)
|
|
469
|
+
if (targets.length === 0) break
|
|
470
|
+
let accepted = false
|
|
471
|
+
for (const t of targets) {
|
|
472
|
+
try {
|
|
473
|
+
const answer = t.session.deliver(text)
|
|
474
|
+
if (answer.delivered === true) { writeAll(t, answer.send); accepted = true }
|
|
475
|
+
} catch (error) { onError(error) }
|
|
476
|
+
}
|
|
477
|
+
if (accepted) await unlink(path).catch(() => {})
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} finally {
|
|
481
|
+
polling = false
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (dir !== null && pollMs > 0) {
|
|
485
|
+
poller = setInterval(() => {
|
|
486
|
+
if (!polling) pollTask = pollSpool().catch(onError)
|
|
487
|
+
}, pollMs)
|
|
488
|
+
if (typeof poller.unref === 'function') poller.unref()
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let closePromise = null
|
|
492
|
+
return {
|
|
493
|
+
server,
|
|
494
|
+
xmpp,
|
|
495
|
+
/** The live connections' `{bare, full}` pairs, for a host's own view. */
|
|
496
|
+
sessions () {
|
|
497
|
+
return Array.from(connections, (c) => ({ bare: c.bare, full: c.full }))
|
|
498
|
+
},
|
|
499
|
+
/** Stanzas held for accounts with no bound session. */
|
|
500
|
+
pending () {
|
|
501
|
+
return new Map(pending)
|
|
502
|
+
},
|
|
503
|
+
close () {
|
|
504
|
+
if (closePromise !== null) return closePromise
|
|
505
|
+
closePromise = (async () => {
|
|
506
|
+
if (poller !== null) clearInterval(poller)
|
|
507
|
+
for (const c of Array.from(connections)) {
|
|
508
|
+
endSession(c)
|
|
509
|
+
c.socket.destroy()
|
|
510
|
+
}
|
|
511
|
+
for (const socket of transports) socket.destroy()
|
|
512
|
+
try {
|
|
513
|
+
await new Promise(resolve => server.close(resolve))
|
|
514
|
+
await pollTask
|
|
515
|
+
await rosterWrites
|
|
516
|
+
if (persistenceError !== null) throw persistenceError
|
|
517
|
+
} finally {
|
|
518
|
+
xmpp.close()
|
|
519
|
+
}
|
|
520
|
+
})()
|
|
521
|
+
return closePromise
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Start a server and resolve once it is listening.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} options as `createXmppServer`, plus `port` and `host`
|
|
530
|
+
* @returns {Promise<{server: object, xmpp: object, port: number, host: string,
|
|
531
|
+
* origin: string, close: Function, sessions: Function}>}
|
|
532
|
+
*/
|
|
533
|
+
export async function listen (options = {}) {
|
|
534
|
+
const port = typeof options.port === 'number' ? options.port : 0
|
|
535
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
536
|
+
const created = await createXmppServer(options)
|
|
537
|
+
try {
|
|
538
|
+
await new Promise((resolve, reject) => {
|
|
539
|
+
created.server.once('error', reject)
|
|
540
|
+
created.server.listen(port, host, resolve)
|
|
541
|
+
})
|
|
542
|
+
} catch (error) {
|
|
543
|
+
await created.close()
|
|
544
|
+
throw error
|
|
545
|
+
}
|
|
546
|
+
const bound = created.server.address()
|
|
547
|
+
const scheme = options.cert !== undefined && options.key !== undefined ? 'xmpps' : 'xmpp'
|
|
548
|
+
return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}` }
|
|
549
|
+
}
|