@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.
@@ -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
+ }