@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/inpage.mjs
ADDED
|
@@ -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
|
+
}
|
package/xmpp/router.mjs
ADDED
|
@@ -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
|
+
}
|