@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/lws/dataset.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// An LWS storage as an RDF dataset (https://github.com/danbri/factoidal/issues/1011,
|
|
2
|
+
// version 2): a table-of-contents graph named by the storage description IRI
|
|
3
|
+
// (the containment as lws:items, the listing members, the link from each
|
|
4
|
+
// resource to its linkset), one named graph per RDF document, one named graph
|
|
5
|
+
// per linkset, and the entity tag of every document and linkset read.
|
|
6
|
+
// Design record: docs/designissues/2026-10-25-lws-dataset-view.md
|
|
7
|
+
//
|
|
8
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
9
|
+
// Loop. Start at the storage root; ask the LWS client (lws/client.mjs) to
|
|
10
|
+
// list a container; give the engine the listing and the container IRI (op
|
|
11
|
+
// lwsViewPlan), which answers which members to list, which to read and whose
|
|
12
|
+
// linksets to read; read those with the client (read, readLinkset); give the
|
|
13
|
+
// engine every listing, read and linkset read (op lwsDatasetView), which
|
|
14
|
+
// answers the dataset as N-Quads with the evidence and the name of the
|
|
15
|
+
// table-of-contents graph; open that text as a Dataset. Every decision is
|
|
16
|
+
// Lean: which members are containers, which media types have an RDF reader
|
|
17
|
+
// and which resources have a linkset to read
|
|
18
|
+
// (L4Factoidal/LWS/DatasetViewRead.lean), the entity tag and the graph of
|
|
19
|
+
// each read, the links of each linkset, the storage description IRI, and the
|
|
20
|
+
// view itself (L4Factoidal/LWS/DatasetView.lean). This file reads no header,
|
|
21
|
+
// no link and chooses no media type.
|
|
22
|
+
//
|
|
23
|
+
// Limits (stated in API.md): a full crawl on every open; one HEAD and one GET
|
|
24
|
+
// per resource for its linkset; no search index; no caching; the snapshot is
|
|
25
|
+
// a sequence of GETs, not one read of the storage.
|
|
26
|
+
|
|
27
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
28
|
+
import { parse } from '../index.mjs'
|
|
29
|
+
import { createLwsClient, LwsClientError } from './client.mjs'
|
|
30
|
+
import { FactoidalError } from '../lib/errors.mjs'
|
|
31
|
+
|
|
32
|
+
/** The engine ops this host needs, beyond the client's. */
|
|
33
|
+
export const LWS_DATASET_OPS = ['lwsViewPlan', 'lwsDatasetView']
|
|
34
|
+
|
|
35
|
+
function isUnknownOp (error) {
|
|
36
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
37
|
+
return text.indexOf('unknown op') >= 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the loaded engine carries the dataset-view ops.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} engine the handle from `loadEngine()`
|
|
44
|
+
* @returns {Promise<{available: boolean, reason: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function lwsDatasetOpsAvailable (engine) {
|
|
47
|
+
try {
|
|
48
|
+
const plan = engine.call('lwsViewPlan', ['{}'])
|
|
49
|
+
if (!plan || !Array.isArray(plan.metadata)) {
|
|
50
|
+
return {
|
|
51
|
+
available: false,
|
|
52
|
+
reason: 'the loaded l4factoidal WebAssembly module carries version 1 of lwsViewPlan ' +
|
|
53
|
+
'(no metadata member; rebuild formal/lean4/Wasm)'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { available: true, reason: 'lwsViewPlan answered' }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (isUnknownOp(error)) {
|
|
59
|
+
return {
|
|
60
|
+
available: false,
|
|
61
|
+
reason: 'the loaded l4factoidal WebAssembly module does not carry lwsViewPlan ' +
|
|
62
|
+
'(rebuild formal/lean4/Wasm)'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { available: true, reason: 'lwsViewPlan answered with an engine error' }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Crawl an LWS storage and open it as a Dataset.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} storageRootUrl the URL of the storage root container
|
|
73
|
+
* @param {object} [options]
|
|
74
|
+
* @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
|
|
75
|
+
* @param {object} [options.auth] passed to `createLwsClient`:
|
|
76
|
+
* `{ subjectToken, subjectTokenType }`, `{ selfIssued }` or `{ session }`
|
|
77
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
78
|
+
* @param {number} [options.maxContainers] stop with an error after listing
|
|
79
|
+
* this many containers (default 10000)
|
|
80
|
+
* @returns {Promise<object>} `{ dataset, toc, evidence, skipped, graphs,
|
|
81
|
+
* query(sparql, options), dispose() }`
|
|
82
|
+
*/
|
|
83
|
+
export async function openLwsDataset (storageRootUrl, options = {}) {
|
|
84
|
+
if (typeof storageRootUrl !== 'string') {
|
|
85
|
+
throw new TypeError('openLwsDataset: storageRootUrl must be a string')
|
|
86
|
+
}
|
|
87
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
88
|
+
const probe = await lwsDatasetOpsAvailable(engine)
|
|
89
|
+
if (!probe.available) {
|
|
90
|
+
throw new FactoidalError(`openLwsDataset: ${probe.reason}`, { code: 'op.unknown' })
|
|
91
|
+
}
|
|
92
|
+
const auth = options.auth !== undefined && options.auth !== null ? options.auth : {}
|
|
93
|
+
const client = await createLwsClient({ ...auth, engine, fetch: options.fetch })
|
|
94
|
+
const maxContainers = typeof options.maxContainers === 'number' ? options.maxContainers : 10000
|
|
95
|
+
|
|
96
|
+
const queue = [storageRootUrl]
|
|
97
|
+
const listed = new Set()
|
|
98
|
+
const listings = []
|
|
99
|
+
const reads = []
|
|
100
|
+
const linksets = []
|
|
101
|
+
const skipped = []
|
|
102
|
+
while (queue.length > 0) {
|
|
103
|
+
const container = queue.shift()
|
|
104
|
+
if (listed.has(container)) continue
|
|
105
|
+
if (listed.size >= maxContainers) {
|
|
106
|
+
throw new FactoidalError(`openLwsDataset: more than ${maxContainers} containers`,
|
|
107
|
+
{ code: 'lws.dataset.limit' })
|
|
108
|
+
}
|
|
109
|
+
listed.add(container)
|
|
110
|
+
const done = await client.list(container)
|
|
111
|
+
listings.push({ container, done })
|
|
112
|
+
const plan = engine.call('lwsViewPlan', [JSON.stringify(done), container])
|
|
113
|
+
for (const c of plan.containers) queue.push(c)
|
|
114
|
+
for (const s of plan.skipped) skipped.push(s)
|
|
115
|
+
for (const resource of plan.resources) {
|
|
116
|
+
try {
|
|
117
|
+
reads.push({ resource, done: await client.read(resource) })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!(error instanceof LwsClientError)) throw error
|
|
120
|
+
skipped.push({ resource, reason: `read: ${error.reason}` })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const resource of plan.metadata) {
|
|
124
|
+
try {
|
|
125
|
+
linksets.push({ resource, done: await client.readLinkset(resource) })
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!(error instanceof LwsClientError)) throw error
|
|
128
|
+
skipped.push({ resource, reason: `linkset: ${error.reason}` })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const view = engine.call('lwsDatasetView', [JSON.stringify({ listings, reads, linksets })])
|
|
134
|
+
for (const s of view.skipped) skipped.push(s)
|
|
135
|
+
const dataset = await parse(view.nquads, { format: 'nquads' })
|
|
136
|
+
const evidence = view.evidence.map((e) => ({ ...e }))
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
dataset,
|
|
140
|
+
/** The name of the table-of-contents graph: the storage description IRI. */
|
|
141
|
+
toc: view.toc,
|
|
142
|
+
/** Each RDF document read, `{ resource, etag, lastModified }`, then each
|
|
143
|
+
* linkset read, `{ resource, linkset, etag }`. */
|
|
144
|
+
evidence,
|
|
145
|
+
/** Each member or read left out of the dataset: `{ resource, reason }`. */
|
|
146
|
+
skipped,
|
|
147
|
+
/** The number of named graphs (the table of contents, the documents and
|
|
148
|
+
* the linksets). */
|
|
149
|
+
graphs: view.graphs,
|
|
150
|
+
/**
|
|
151
|
+
* Run a SPARQL query over the dataset.
|
|
152
|
+
* @returns {Promise<{result: *, evidence: Array<object>}>} `result` as
|
|
153
|
+
* `Dataset.query` answers; `evidence` the documents and linksets read
|
|
154
|
+
*/
|
|
155
|
+
async query (sparql, queryOptions) {
|
|
156
|
+
return { result: await dataset.query(sparql, queryOptions), evidence: evidence.slice() }
|
|
157
|
+
},
|
|
158
|
+
dispose () { dataset.dispose() }
|
|
159
|
+
}
|
|
160
|
+
}
|
package/lws/inpage.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// factoidal/lws/inpage.mjs — one Lean LWS storage with no network: it runs
|
|
2
|
+
// in the page (or a worker, or Node) beside the code that uses it, and is
|
|
3
|
+
// reached through a `fetch`-shaped function.
|
|
4
|
+
//
|
|
5
|
+
// import { loadEngine } from '@factoidal/core/browser'
|
|
6
|
+
// import { openInPageLwsStorage } from '@factoidal/core/lws/inpage'
|
|
7
|
+
// const pod = await openInPageLwsStorage({ engine: await loadEngine(),
|
|
8
|
+
// baseIri: 'https://pod.foafos.local/', principal: 'urn:foafos:shell',
|
|
9
|
+
// rootAcl, storageKey, snapshot: saved,
|
|
10
|
+
// onSnapshot: (s) => save(s) })
|
|
11
|
+
// const r = await pod.fetch('https://pod.foafos.local/notes/a.txt', { method: 'PUT', body: 'hi' })
|
|
12
|
+
//
|
|
13
|
+
// The storage has a component principal (issue 1238): every request is that
|
|
14
|
+
// agent's, no token is read, and Web Access Control still decides it
|
|
15
|
+
// (L4Factoidal/LWS/Authorization.lean). Whoever holds the object can use it,
|
|
16
|
+
// so the host page decides which code gets it, as with xmpp/inpage.mjs.
|
|
17
|
+
//
|
|
18
|
+
// It is kept as one snapshot (`lwsSnapshot`), which the engine seals when a
|
|
19
|
+
// `storageKey` is given. After each step that changed the storage this file
|
|
20
|
+
// asks the engine for the snapshot and gives it to `onSnapshot`; the caller
|
|
21
|
+
// keeps it (IndexedDB, a file) and passes it back as `snapshot` to reopen.
|
|
22
|
+
// The engine checks that snapshot and refuses one sealed under another key.
|
|
23
|
+
//
|
|
24
|
+
// The engine makes every protocol decision (status, headers, bodies). This
|
|
25
|
+
// file turns a fetch call into the request record of the wasm ABI
|
|
26
|
+
// (docs/lws-solid-conformance.md, "wasm ABI") and the response record into a
|
|
27
|
+
// Response. Notifications are off: there is no network to deliver them on.
|
|
28
|
+
// No Node API; `crypto.getRandomValues`, `Request` and `Response` only.
|
|
29
|
+
|
|
30
|
+
/** Random bytes for the engine, base64 (see lws/server.mjs, entropyBase64). */
|
|
31
|
+
function entropyBase64 (size = 32) {
|
|
32
|
+
const bytes = new Uint8Array(size)
|
|
33
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
34
|
+
let text = ''
|
|
35
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
36
|
+
return btoa(text)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleOf (envelope) {
|
|
40
|
+
if (envelope === null || typeof envelope !== 'object') return null
|
|
41
|
+
for (const name of ['handle', 'id']) {
|
|
42
|
+
const value = envelope[name]
|
|
43
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
44
|
+
if (typeof value === 'number') return String(value)
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} options
|
|
51
|
+
* @param {object} options.engine a loaded engine handle (`loadEngine()`), with callBlobIO
|
|
52
|
+
* @param {string} options.baseIri the absolute IRI the storage root maps to; fetch
|
|
53
|
+
* reaches only IRIs under it
|
|
54
|
+
* @param {string} options.principal the component principal (an IRI); every request is its
|
|
55
|
+
* @param {string} [options.rootAcl] the root ACL (Turtle) of a new storage
|
|
56
|
+
* @param {string} [options.owner] the WebID the storage advertises
|
|
57
|
+
* @param {string} [options.storageKey] key text (64 hex digits or base64url); seals the snapshot
|
|
58
|
+
* @param {object} [options.snapshot] a snapshot to continue from (from onSnapshot)
|
|
59
|
+
* @param {function} [options.onSnapshot] (snapshot) => void, after each change
|
|
60
|
+
* @param {number} [options.maxBody] the largest content of one write (the engine's default otherwise)
|
|
61
|
+
* @returns {Promise<{fetch: Function, snapshot: Function, close: Function, baseIri: string}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function openInPageLwsStorage (options = {}) {
|
|
64
|
+
const { engine } = options
|
|
65
|
+
if (!engine || typeof engine.callBlobIO !== 'function') throw new TypeError('openInPageLwsStorage: an engine with callBlobIO is required')
|
|
66
|
+
if (typeof options.baseIri !== 'string') throw new TypeError('openInPageLwsStorage: baseIri is required')
|
|
67
|
+
if (typeof options.principal !== 'string') throw new TypeError('openInPageLwsStorage: principal is required')
|
|
68
|
+
const base = new URL(options.baseIri)
|
|
69
|
+
const config = { baseIri: base.href, principal: options.principal, notifications: false, entropy: entropyBase64() }
|
|
70
|
+
for (const name of ['rootAcl', 'owner', 'storageKey']) {
|
|
71
|
+
if (typeof options[name] === 'string') config[name] = options[name]
|
|
72
|
+
}
|
|
73
|
+
if (typeof options.maxBody === 'number') config.maxBody = options.maxBody
|
|
74
|
+
if (options.snapshot !== undefined && options.snapshot !== null) config.snapshot = options.snapshot
|
|
75
|
+
const opened = engine.call('lwsOpen', [JSON.stringify(config)])
|
|
76
|
+
const handle = handleOf(opened)
|
|
77
|
+
if (handle === null) throw new Error('lwsOpen answered with no handle')
|
|
78
|
+
const onSnapshot = typeof options.onSnapshot === 'function' ? options.onSnapshot : null
|
|
79
|
+
let closed = false
|
|
80
|
+
|
|
81
|
+
const snapshot = () => {
|
|
82
|
+
const answer = engine.call('lwsSnapshot', [handle, JSON.stringify({ nonce: entropyBase64(12) })])
|
|
83
|
+
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
84
|
+
throw new Error('lwsSnapshot answered with no snapshot')
|
|
85
|
+
}
|
|
86
|
+
return answer.snapshot
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A fetch to this storage: the same arguments as `fetch`, a real Response back. */
|
|
90
|
+
async function podFetch (input, init) {
|
|
91
|
+
if (closed) throw new TypeError('this storage is closed')
|
|
92
|
+
const request = new Request(input, init)
|
|
93
|
+
const url = new URL(request.url)
|
|
94
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
|
|
95
|
+
throw new TypeError('the IRI is not in this storage')
|
|
96
|
+
}
|
|
97
|
+
const target = '/' + url.pathname.slice(base.pathname.length) + url.search
|
|
98
|
+
const content = request.method === 'GET' || request.method === 'HEAD'
|
|
99
|
+
? new Uint8Array(0)
|
|
100
|
+
: new Uint8Array(await request.arrayBuffer())
|
|
101
|
+
const record = {
|
|
102
|
+
method: request.method,
|
|
103
|
+
target,
|
|
104
|
+
headers: [...request.headers],
|
|
105
|
+
now: Math.floor(Date.now() / 1000),
|
|
106
|
+
random: entropyBase64(16)
|
|
107
|
+
}
|
|
108
|
+
const got = engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
|
|
109
|
+
const envelope = got.envelope
|
|
110
|
+
if (envelope === null || typeof envelope !== 'object') throw new Error('lwsStep answered with no envelope')
|
|
111
|
+
// A storage with a component principal and no files has nothing to
|
|
112
|
+
// fetch, resolve or read; an engine that asks is not one this host runs.
|
|
113
|
+
for (const ask of ['fetch', 'resolve', 'read']) {
|
|
114
|
+
if (envelope[ask] !== undefined && envelope[ask] !== null) throw new Error(`lwsStep asked this host to ${ask}, which an in-page storage cannot`)
|
|
115
|
+
}
|
|
116
|
+
const answer = (envelope.response !== null && typeof envelope.response === 'object') ? envelope.response : envelope
|
|
117
|
+
if (typeof answer.status !== 'number') throw new Error('lwsStep answered with no status in its response record')
|
|
118
|
+
if (envelope.changed === true && onSnapshot !== null) onSnapshot(snapshot())
|
|
119
|
+
const headers = new Headers()
|
|
120
|
+
for (const [name, value] of Array.isArray(answer.headers) ? answer.headers : []) headers.append(name, value)
|
|
121
|
+
// No content is a null body: a Response refuses an empty one with 204 or 304.
|
|
122
|
+
const body = got.bytes instanceof Uint8Array && got.bytes.length > 0 ? got.bytes : null
|
|
123
|
+
return new Response(body, { status: answer.status, headers })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
baseIri: base.href,
|
|
128
|
+
fetch: podFetch,
|
|
129
|
+
snapshot,
|
|
130
|
+
close () {
|
|
131
|
+
closed = true
|
|
132
|
+
try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/lws/persist.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
|
|
|
64
64
|
/**
|
|
65
65
|
* The text of the storage mode: `explicit` when the caller gave a string,
|
|
66
66
|
* and otherwise the environment variable, passed unread; the engine checks
|
|
67
|
-
* it. An empty value is none (the
|
|
67
|
+
* it. An empty value is none (the host's default with a DIR, "files").
|
|
68
68
|
*/
|
|
69
69
|
export function storageModeText (explicit) {
|
|
70
70
|
if (typeof explicit === 'string') return explicit
|
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.30.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
|
|