@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/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/bin/factoidal.mjs +113 -23
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/version.mjs +1 -1
- 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/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: "
|
|
254
|
-
* file
|
|
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
|
|
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
|
-
/**
|
|
593
|
-
|
|
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
|
-
|
|
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', () =>
|
|
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:
|
|
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.
|
|
4
|
-
"description": "EXPERIMENTAL
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|