@factoidal/core 0.29.0 → 0.31.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/lws/server.mjs CHANGED
@@ -250,8 +250,9 @@ function contentBytes (request) {
250
250
  * @param {boolean} [options.realClock] stamp each request with this host's clock
251
251
  * @param {string|null} [options.storageKey] the storage key text (64 hex digits or base64url);
252
252
  * default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
253
- * @param {string} [options.storage] where DIR keeps the storage: "snapshot" (default, one
254
- * file) or "files" (one file per body and a journal, issue 918); passed unread
253
+ * @param {string} [options.storage] where DIR keeps the storage: "files" (default, one
254
+ * file per body and a journal, issue 918) or "snapshot" (one file written whole after
255
+ * each change); passed unread
255
256
  * @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
256
257
  * origins to answer; default: echo any valid request Origin. Passed unread
257
258
  * @param {string} [options.lockCommand] the command named in the lock file of DIR
@@ -270,12 +271,20 @@ export async function openLwsStorage (options = {}) {
270
271
  // The pagination threshold and page size of container listings; the
271
272
  // engine decides what it means (L4Factoidal/LWS/Pagination.lean).
272
273
  if (typeof options.pageSize === 'number') config.pageSize = options.pageSize
274
+ // The largest content of one write; the engine answers 413 above it
275
+ // (L4Factoidal/LWS/Operations.lean, step_tooLarge; issue 1240).
276
+ if (typeof options.maxBody === 'number') config.maxBody = options.maxBody
273
277
  // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
274
278
  // through unread; `insecureNoAuth` is the explicit development mode.
275
279
  // With neither, the engine enforces authorization with no trusted
276
280
  // issuer.
277
281
  if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
278
282
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
283
+ // A component principal (issue 1238): a storage a component opens
284
+ // in-process. No token is read; every request is this agent's, and WAC
285
+ // decides it (Authorization.decide_principal_agents). Never served over
286
+ // HTTP: createLwsServer refuses it.
287
+ if (typeof options.principal === 'string') config.principal = options.principal
279
288
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
280
289
  // Notifications are on unless the caller turns them off; the engine
281
290
  // decides everything else about them.
@@ -339,10 +348,13 @@ export async function openLwsStorage (options = {}) {
339
348
  // With "files" the engine names the files to read (`load`) and gives the
340
349
  // plan of every write (`lwsPersist`), and this host reads no snapshot.
341
350
  // With a DIR and no `storage` option, the environment variable
342
- // FACTOIDAL_LWS_STORAGE is the value; without a DIR there is nothing to
351
+ // FACTOIDAL_LWS_STORAGE is the value, and without it "files" (2026-11-03):
352
+ // a write then costs what it changes, not the size of the storage. A DIR
353
+ // that holds a snapshot is migrated at the first start (lws-state.json is
354
+ // renamed lws-state.json.migrated). Without a DIR there is nothing to
343
355
  // keep, and only an explicit option is passed.
344
356
  const dir = typeof options.dir === 'string' ? options.dir : null
345
- const storage = dir !== null ? storageModeText(options.storage) : options.storage
357
+ const storage = dir !== null ? (storageModeText(options.storage) ?? 'files') : options.storage
346
358
  if (typeof storage === 'string') config.storage = storage
347
359
  // One writing process per storage directory
348
360
  // (https://github.com/danbri/factoidal/issues/1135): the lock is taken
@@ -589,13 +601,37 @@ export async function openLwsStorage (options = {}) {
589
601
  }
590
602
  }
591
603
 
592
- /** Read a Node request body as its bytes. */
593
- function readBody (request) {
604
+ /** The engine's default `maxBody` (L4Factoidal/LWS/Operations.lean). */
605
+ export const DEFAULT_MAX_BODY = 16 * 1024 * 1024
606
+
607
+ /**
608
+ * Read a Node request body as its bytes, at most `limit + 1` of them. Past
609
+ * the limit it keeps the first `limit + 1` bytes, stops reading and says so
610
+ * (`over`), so an oversized upload never reaches memory whole (issue 1240).
611
+ * The engine decides the answer: content over its `maxBody` is refused with
612
+ * 413 and changes nothing (`step_tooLarge`, L4Factoidal/LWS/Operations.lean).
613
+ */
614
+ function readBody (request, limit = DEFAULT_MAX_BODY) {
594
615
  return new Promise((resolve, reject) => {
595
616
  const chunks = []
596
- request.on('data', (chunk) => chunks.push(chunk))
617
+ let size = 0
618
+ let over = false
619
+ const done = () => resolve({ bytes: new Uint8Array(Buffer.concat(chunks)), over })
620
+ request.on('data', (chunk) => {
621
+ if (over) return
622
+ if (size + chunk.length > limit) {
623
+ chunks.push(chunk.subarray(0, limit + 1 - size))
624
+ size = limit + 1
625
+ over = true
626
+ done()
627
+ request.resume()
628
+ return
629
+ }
630
+ size += chunk.length
631
+ chunks.push(chunk)
632
+ })
597
633
  request.on('error', reject)
598
- request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
634
+ request.on('end', () => { if (!over) done() })
599
635
  })
600
636
  }
601
637
 
@@ -623,6 +659,11 @@ function headerPairs (raw) {
623
659
  * setBaseIri: Function}>}
624
660
  */
625
661
  export async function createLwsServer (options = {}) {
662
+ if (options.principal !== undefined) {
663
+ // Served over HTTP, a principal storage would let every client act as
664
+ // the component. It is for in-process use only (issue 1238).
665
+ throw new LwsHostError('a storage with a component principal is in-process only; it is not served over HTTP')
666
+ }
626
667
  const http = await import('node:http')
627
668
  const onError = typeof options.onError === 'function'
628
669
  ? options.onError
@@ -675,8 +716,13 @@ export async function createLwsServer (options = {}) {
675
716
  method: request.method,
676
717
  target: request.url,
677
718
  headers: headerPairs(request.rawHeaders),
678
- body: await readBody(request)
719
+ body: new Uint8Array(0)
679
720
  }
721
+ const read = await readBody(request, typeof settings.maxBody === 'number' ? settings.maxBody : DEFAULT_MAX_BODY)
722
+ record.body = read.bytes
723
+ // The rest of an oversized body is not read: the connection closes
724
+ // after the answer. The answer is the engine's.
725
+ if (read.over) response.shouldKeepAlive = false
680
726
  } catch (error) {
681
727
  onError(error)
682
728
  response.writeHead(500, { 'content-type': 'text/plain' })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.29.0",
4
- "description": "EXPERIMENTAL UNREVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
3
+ "version": "0.31.0",
4
+ "description": "EXPERIMENTAL UNDER-REVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",
7
7
  "rdf",
@@ -70,6 +70,7 @@
70
70
  "./store-host/paths": "./store-host/paths.mjs",
71
71
  "./xmpp": "./xmpp/index.mjs",
72
72
  "./xmpp/server": "./xmpp/server.mjs",
73
+ "./xmpp/inpage": "./xmpp/inpage.mjs",
73
74
  "./cipher-host": "./lib/cipher-host.mjs",
74
75
  "./store": {
75
76
  "default": "./bin/store.mjs"
@@ -92,9 +93,15 @@
92
93
  "./webdav": {
93
94
  "default": "./webdav/server.mjs"
94
95
  },
96
+ "./lws/inpage": {
97
+ "default": "./lws/inpage.mjs"
98
+ },
95
99
  "./lws/client": {
96
100
  "default": "./lws/client.mjs"
97
101
  },
102
+ "./lws/dataset": {
103
+ "default": "./lws/dataset.mjs"
104
+ },
98
105
  "./authz": {
99
106
  "default": "./authz/server.mjs"
100
107
  },
package/xmpp/README.md CHANGED
@@ -65,8 +65,12 @@ against an older committed bundle instead of getting a confusing
65
65
  mkdir -p state && printf 'juliet:r0m30\nromeo:juli3t\n' > state/accounts
66
66
  npx factoidal xmpp-serve state --domain localhost # plaintext on 5222
67
67
  npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem # XEP-0368 direct TLS on 5223
68
+ npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls # STARTTLS required on 5222
68
69
  ```
69
70
 
71
+ Without `--cert` and `--key` the listener has no TLS and offers PLAIN, so
72
+ the command binds it to a loopback address only.
73
+
70
74
  ```js
71
75
  import { listen } from '@factoidal/core/xmpp/server'
72
76
  const running = await listen({ dir: 'state', domain: 'localhost', port: 0 })
@@ -90,6 +94,7 @@ process per connection behind socat) serves; the wasm session ops are
90
94
  | `xmppSessionFeed` | `[session, text]` | `{ ok, send: [...], route: [{to, text}], presence: [...], rostersSaved: [...], close, stage, bare, full, bufferedBytes }` |
91
95
  | `xmppSessionDeliver` | `[session, text]` | `{ ok, delivered, send }` — `delivered:false` for an unbound session; nothing is lost |
92
96
  | `xmppSessionClose` | `[session]` | `{ ok, send }` |
97
+ | `xmppSessionTlsUp` | `[session]` | `{ ok, stage }` — after a feed answered `startTls: true` and the host finished the TLS handshake; refused otherwise |
93
98
  | `xmppRosterGet` / `xmppRosterSet` | `[server, bare]` / `[server, bare, encoded]` | the roster in the engine's own on-disk encoding |
94
99
  | `xmppClose` | `[server]` | `{ ok, sessions }` |
95
100
 
@@ -120,7 +125,10 @@ What it speaks: RFC 6120 stream negotiation, stream errors, SASL PLAIN
120
125
  and SCRAM-SHA-256, resource binding; RFC 6121 roster, presence
121
126
  subscription and broadcast, message delivery with the server-stamped
122
127
  `from`; XEP-0030 `disco#info`; XEP-0368 direct TLS with `--cert`/`--key`.
123
- Not: STARTTLS, server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
128
+ RFC 6120 section 5 STARTTLS with `startTls: true` (op `xmppSessionTlsUp`;
129
+ the engine decides, the host does the handshake; see the theorems in
130
+ `formal/lean4/L4Factoidal/XMPP/Server.lean`).
131
+ Not: server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
124
132
  Known limitation shared with the native harness: one SCRAM salt per
125
133
  server (`saltB64` in the config), not one per account.
126
134
 
@@ -0,0 +1,197 @@
1
+ // factoidal/xmpp/inpage.mjs — the Lean XMPP server with no network: it
2
+ // runs in the page (or a worker, or Node) beside the code that talks to it,
3
+ // and each connection is a pair of in-order message channels.
4
+ //
5
+ // import { loadEngine } from '@factoidal/core/browser'
6
+ // import { openInPageXmppServer, inPageWebSocket } from '@factoidal/core/xmpp/inpage'
7
+ // const server = await openInPageXmppServer({ engine: await loadEngine(),
8
+ // domain: 'foafos.local', mucDomain: 'rooms.foafos.local',
9
+ // accounts: [{ username: 'shell', password }] })
10
+ // const link = server.connect() // { send(message), onmessage, close() }
11
+ // const WS = inPageWebSocket(server) // a WebSocket-shaped class for
12
+ // new WS('xmpp-inpage:', 'xmpp') // Strophe.js and other RFC 7395 clients
13
+ //
14
+ // A message is one RFC 7395 message (https://www.rfc-editor.org/rfc/rfc7395):
15
+ // `<open/>` and `<close/>` in the framing namespace, then one complete
16
+ // element each. The engine's message carrier (`xmppMsgOpen`, `xmppMsgFeed`,
17
+ // `xmppMsgDeliver`, `xmppMsgClose`; Wasm/Ops/XmppSession.lean) makes every
18
+ // protocol decision; this file only moves messages and performs the routes
19
+ // (router.mjs, shared with the Node host). No Node or browser API beyond
20
+ // `crypto.getRandomValues` and `queueMicrotask`.
21
+ //
22
+ // There is no socket, so there is no Origin to check: whoever holds the
23
+ // server object can connect, and the accounts' passwords are what the
24
+ // engine checks (SCRAM-SHA-256). The host page decides which code gets it.
25
+
26
+ import { createXmppRouter } from './router.mjs'
27
+
28
+ /** Unpredictable values for the stream id and the SCRAM server nonce
29
+ * (RFC 6120 section 4.7.3, RFC 5802 section 5.1); the engine asks the host. */
30
+ function randomToken (bytes = 18) {
31
+ const b = new Uint8Array(bytes)
32
+ globalThis.crypto.getRandomValues(b)
33
+ let s = ''
34
+ for (const x of b) s += String.fromCharCode(x)
35
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
36
+ }
37
+
38
+ /**
39
+ * @param {object} options
40
+ * @param {object} options.engine a loaded engine handle (`loadEngine()`)
41
+ * @param {string} [options.domain] default `localhost`
42
+ * @param {Array<{username: string, password: string}>} [options.accounts]
43
+ * @param {string} [options.mucDomain] XEP-0045 rooms (L4Factoidal/XMPP/Muc.lean)
44
+ * @param {string} [options.mixDomain] XEP-0369 channels (L4Factoidal/XMPP/Mix.lean)
45
+ * @param {string} [options.gc3Domain] GC3 prototype groups (L4Factoidal/XMPP/Gc3.lean)
46
+ * @param {Function} [options.onError]
47
+ */
48
+ export async function openInPageXmppServer (options = {}) {
49
+ const engine = options.engine
50
+ if (!engine || typeof engine.call !== 'function') throw new Error('openInPageXmppServer: options.engine is required')
51
+ const onError = typeof options.onError === 'function' ? options.onError : (e) => console.error(`xmpp-inpage: ${e.message}`)
52
+ const config = {}
53
+ for (const k of ['domain', 'mucDomain', 'mixDomain', 'gc3Domain', 'saltB64']) {
54
+ if (typeof options[k] === 'string') config[k] = options[k]
55
+ }
56
+ if (Array.isArray(options.accounts)) config.accounts = options.accounts
57
+ if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
58
+ const opened = engine.call('xmppOpen', [JSON.stringify(config)])
59
+ if (typeof opened.handle !== 'string') throw new Error('xmppOpen answered with no handle')
60
+ const handle = opened.handle
61
+ const router = createXmppRouter({ onError })
62
+ let closed = false
63
+
64
+ function connect () {
65
+ if (closed) throw new Error('the in-page XMPP server is closed')
66
+ const got = engine.call('xmppMsgOpen', [handle, JSON.stringify({ streamId: randomToken(), nonce: randomToken() })])
67
+ if (typeof got.handle !== 'string') throw new Error('xmppMsgOpen answered with no handle (an engine without the message carrier?)')
68
+ const session = got.handle
69
+ let open = true
70
+ // Messages to the client go out in order, never inside an engine call.
71
+ const emit = (msgs) => {
72
+ for (const m of msgs) {
73
+ queueMicrotask(() => {
74
+ if (typeof link.onmessage === 'function') {
75
+ try { link.onmessage(m) } catch (error) { onError(error) }
76
+ }
77
+ })
78
+ }
79
+ }
80
+ const end = () => {
81
+ if (!open) return
82
+ open = false
83
+ router.connections.delete(c)
84
+ let answer
85
+ try { answer = engine.call('xmppMsgClose', [session]) } catch (error) { onError(error); answer = { send: [], routeFull: [] } }
86
+ for (const r of answer.routeFull || []) router.routeFull(null, r.to, r.text, null)
87
+ emit(answer.send || [])
88
+ queueMicrotask(() => { if (typeof link.onclose === 'function') link.onclose() })
89
+ }
90
+ const c = {
91
+ bare: null,
92
+ full: null,
93
+ deliver (text) {
94
+ if (!open) return false
95
+ const answer = engine.call('xmppMsgDeliver', [session, text])
96
+ if (answer.delivered === true) { emit(answer.send); return true }
97
+ return false
98
+ }
99
+ }
100
+ router.connections.add(c)
101
+ const link = {
102
+ onmessage: null,
103
+ onclose: null,
104
+ get open () { return open },
105
+ /** One whole RFC 7395 message from the client. */
106
+ send (message) {
107
+ if (!open) throw new Error('the in-page XMPP connection is closed')
108
+ let answer
109
+ try { answer = engine.call('xmppMsgFeed', [session, String(message)]) } catch (error) { onError(error); end(); return }
110
+ emit(answer.send || [])
111
+ router.apply(c, answer)
112
+ if (answer.close === true) end()
113
+ },
114
+ close: end
115
+ }
116
+ return link
117
+ }
118
+
119
+ return {
120
+ engine,
121
+ handle,
122
+ domain: typeof opened.domain === 'string' ? opened.domain : (config.domain || 'localhost'),
123
+ mucDomain: config.mucDomain || null,
124
+ connect,
125
+ /** The live connections' `{bare, full}` pairs. */
126
+ sessions () { return Array.from(router.connections, (c) => ({ bare: c.bare, full: c.full })) },
127
+ close () {
128
+ if (closed) return
129
+ closed = true
130
+ for (const c of Array.from(router.connections)) router.connections.delete(c)
131
+ try { engine.call('xmppClose', [handle]) } catch (_error) { /* already gone */ }
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * A WebSocket-shaped class whose instances are connections to `server`, so
138
+ * an RFC 7395 client written for `WebSocket` (Strophe.js, the foafos XmppWs
139
+ * client) runs unchanged. The URL is not used; the subprotocol is `xmpp`.
140
+ */
141
+ export function inPageWebSocket (server) {
142
+ return class InPageWebSocket {
143
+ static CONNECTING = 0
144
+ static OPEN = 1
145
+ static CLOSING = 2
146
+ static CLOSED = 3
147
+ constructor (url, protocols) {
148
+ this.url = String(url)
149
+ this.protocol = 'xmpp'
150
+ this.readyState = 0
151
+ this.binaryType = 'blob'
152
+ this.bufferedAmount = 0
153
+ this.extensions = ''
154
+ this.onopen = this.onmessage = this.onclose = this.onerror = null
155
+ this._listeners = { open: [], message: [], close: [], error: [] }
156
+ const wanted = Array.isArray(protocols) ? protocols : (protocols === undefined ? [] : [protocols])
157
+ queueMicrotask(() => {
158
+ if (wanted.length > 0 && !wanted.includes('xmpp')) { this._fail(); return }
159
+ try { this._link = server.connect() } catch (_error) { this._fail(); return }
160
+ this._link.onmessage = (data) => this._fire('message', { type: 'message', data, target: this })
161
+ this._link.onclose = () => this._closed(1000)
162
+ this.readyState = 1
163
+ this._fire('open', { type: 'open', target: this })
164
+ })
165
+ }
166
+ addEventListener (type, fn) { this._listeners[type]?.push(fn) }
167
+ removeEventListener (type, fn) {
168
+ const l = this._listeners[type]
169
+ if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1) }
170
+ }
171
+ dispatchEvent (event) { this._fire(event.type, event); return true }
172
+ send (data) {
173
+ if (this.readyState !== 1) throw new Error('InvalidStateError: the connection is not open')
174
+ this._link.send(String(data))
175
+ }
176
+ close (code = 1000) {
177
+ if (this.readyState >= 2) return
178
+ this.readyState = 2
179
+ this._link?.close()
180
+ if (!this._link) this._closed(code)
181
+ }
182
+ _fail () {
183
+ this._fire('error', { type: 'error', target: this })
184
+ this._closed(1006)
185
+ }
186
+ _closed (code) {
187
+ if (this.readyState === 3) return
188
+ this.readyState = 3
189
+ this._fire('close', { type: 'close', code, reason: '', wasClean: code === 1000, target: this })
190
+ }
191
+ _fire (type, event) {
192
+ const prop = this['on' + type]
193
+ if (typeof prop === 'function') prop.call(this, event)
194
+ for (const fn of [...(this._listeners[type] || [])]) fn.call(this, event)
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,96 @@
1
+ // factoidal/xmpp/router.mjs — the routing table of an XMPP host, with no
2
+ // transport: the Node host (`server.mjs`, TCP, TLS, WebSocket) and the
3
+ // in-page host (`inpage.mjs`, whole messages over a MessagePort or a
4
+ // function call) share it. No Node or browser API is used here.
5
+ //
6
+ // A connection is any object with `bare`, `full` (the last values the
7
+ // engine reported, or null) and `deliver(text) → boolean` (hand it a stanza
8
+ // another session routed to it; false when its session refused). The engine
9
+ // decides where every stanza goes (`route`, `routeFull` of a feed answer);
10
+ // this file only finds the connections and calls `deliver`.
11
+
12
+ /**
13
+ * @param {object} [options]
14
+ * @param {Function} [options.onError]
15
+ * @param {number} [options.pendingLimit] stanzas kept per bare JID for the
16
+ * race where a session closed between the engine's decision and the
17
+ * delivery (default 100)
18
+ */
19
+ export function createXmppRouter (options = {}) {
20
+ const onError = typeof options.onError === 'function' ? options.onError : () => {}
21
+ const pendingLimit = typeof options.pendingLimit === 'number' ? options.pendingLimit : 100
22
+ /** Every live connection. */
23
+ const connections = new Set()
24
+ /** bare JID → stanzas routed to it while no session was bound. */
25
+ const pending = new Map()
26
+
27
+ function boundTo (bare) {
28
+ const found = []
29
+ for (const c of connections) if (c.full !== null && c.bare === bare) found.push(c)
30
+ return found
31
+ }
32
+
33
+ function hold (bare, text) {
34
+ if (!pending.has(bare)) pending.set(bare, [])
35
+ const q = pending.get(bare)
36
+ if (q.length < pendingLimit) q.push(text)
37
+ else onError(new Error(`dropped a stanza for ${bare}: the host fallback queue is full`))
38
+ }
39
+
40
+ /**
41
+ * Deliver one stanza to every session bound to `bare`. The engine holds
42
+ * stanzas for accounts with no bound session (L4Factoidal/XMPP/Held.lean,
43
+ * bounded; issue 1235), so a route reaches here only for a bound account.
44
+ * `pending` covers the race where that session closed between the
45
+ * engine's decision and this delivery; it is bounded too.
46
+ */
47
+ function routeStanza (bare, text) {
48
+ const targets = boundTo(bare)
49
+ if (targets.length === 0) { hold(bare, text); return }
50
+ for (const t of targets) {
51
+ let delivered = false
52
+ try { delivered = t.deliver(text) } catch (error) { onError(error); continue }
53
+ if (!delivered) hold(bare, text)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `Out.routeFull`: deliver to the ONE session bound to `full`, now. With no
59
+ * such session (or one that refuses), deliver the engine's bounce, if it
60
+ * gave one, back to the sending session. Never held (RFC 6120 section
61
+ * 10.5.3.2; https://github.com/danbri/factoidal/issues/1234).
62
+ */
63
+ function routeFull (sender, full, text, bounce) {
64
+ for (const t of connections) {
65
+ if (t.full !== full) continue
66
+ try { if (t.deliver(text)) return } catch (error) { onError(error) }
67
+ }
68
+ if (typeof bounce === 'string' && sender !== null) {
69
+ try { sender.deliver(bounce) } catch (error) { onError(error) }
70
+ }
71
+ }
72
+
73
+ /** A session just bound: hand it what was waiting. */
74
+ function flushPending (c) {
75
+ const queue = pending.get(c.bare)
76
+ if (!queue || queue.length === 0) return
77
+ pending.delete(c.bare)
78
+ for (const text of queue) routeStanza(c.bare, text)
79
+ }
80
+
81
+ /**
82
+ * The routing part of one feed answer: the session's JIDs as the engine
83
+ * reports them, then `route` and `routeFull`, then what waited for a
84
+ * session that has just bound.
85
+ */
86
+ function apply (c, answer) {
87
+ const wasBound = c.full !== null
88
+ c.bare = typeof answer.bare === 'string' ? answer.bare : null
89
+ c.full = typeof answer.full === 'string' ? answer.full : null
90
+ for (const r of answer.route || []) routeStanza(r.to, r.text)
91
+ for (const r of answer.routeFull || []) routeFull(c, r.to, r.text, r.bounce)
92
+ if (!wasBound && c.full !== null) flushPending(c)
93
+ }
94
+
95
+ return { connections, pending, boundTo, routeStanza, routeFull, flushPending, apply }
96
+ }