@factoidal/core 0.16.0 → 0.18.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 +23 -0
- package/README.md +3 -2
- package/authz/server.mjs +17 -3
- package/bin/engine.mjs +12 -10
- package/bin/factoidal.mjs +339 -24
- package/bin/pack-host.mjs +31 -1
- package/bin/store.mjs +67 -11
- package/index.d.ts +18 -8
- 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 +34 -15
- package/lib/dataset.mjs +2 -1
- package/lib/lean-entry.mjs +40 -1
- package/lib/store.mjs +10 -8
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +50 -8
- package/lws/server.mjs +34 -3
- package/package.json +6 -1
- package/webdav/server.mjs +293 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "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",
|
|
@@ -88,6 +88,9 @@
|
|
|
88
88
|
"./lws": {
|
|
89
89
|
"default": "./lws/server.mjs"
|
|
90
90
|
},
|
|
91
|
+
"./webdav": {
|
|
92
|
+
"default": "./webdav/server.mjs"
|
|
93
|
+
},
|
|
91
94
|
"./lws/client": {
|
|
92
95
|
"default": "./lws/client.mjs"
|
|
93
96
|
},
|
|
@@ -123,6 +126,7 @@
|
|
|
123
126
|
"lib/",
|
|
124
127
|
"bin/",
|
|
125
128
|
"lws/",
|
|
129
|
+
"webdav/",
|
|
126
130
|
"authz/",
|
|
127
131
|
"solid/",
|
|
128
132
|
"store-host/",
|
|
@@ -142,6 +146,7 @@
|
|
|
142
146
|
"scripts": {
|
|
143
147
|
"build": "bash ../../formal/lean4/Wasm/build-wasm.sh",
|
|
144
148
|
"test": "node --test 'test/*.test.js' 'test/*.test.mjs'",
|
|
149
|
+
"test:jena": "node --test 'test/jena/*.test.mjs'",
|
|
145
150
|
"test:speed": "node ../../tests/perf/npm_speed_gate.mjs",
|
|
146
151
|
"test:costs": "node ../../tests/perf/npm_costs.mjs",
|
|
147
152
|
"prepublishOnly": "npm test"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// A Node HTTP host for the Factoidal WebDAV server.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/1066
|
|
3
|
+
// Design record: docs/designissues/2026-10-27-webdav.md
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
6
|
+
// Carry sockets, files and fetches for the engine, and nothing else:
|
|
7
|
+
//
|
|
8
|
+
// * at start, list CONFIGDIR and give the names to `webdavOpen`; read the
|
|
9
|
+
// files and test the paths the engine names (`need`) and give them to
|
|
10
|
+
// `webdavLoad` until it answers `ready`; write the files it names
|
|
11
|
+
// (`write`, a key file it made); open each local storage directory it
|
|
12
|
+
// names with the ops `lws-serve` uses (`openLwsStorage`, the config passed
|
|
13
|
+
// unread);
|
|
14
|
+
// * per request, give the method, target, header fields and content to
|
|
15
|
+
// `webdavStep`, then carry what the engine answers: an `lws` request to
|
|
16
|
+
// the local storage it names (no socket: the storage handle's own step),
|
|
17
|
+
// a `fetch` to the URL it names, until it answers a `response`, which
|
|
18
|
+
// this file writes back.
|
|
19
|
+
//
|
|
20
|
+
// It makes NO protocol decision. Which files are mounts, what a mount file
|
|
21
|
+
// says, every status code, header field, XML body, lock, property and path
|
|
22
|
+
// mapping is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
|
|
23
|
+
// CLAUDE.md). The one status code written here is 500, for a failure of
|
|
24
|
+
// this host itself. Requests are served one at a time (a queue), because
|
|
25
|
+
// the engine keeps one request in flight per handle.
|
|
26
|
+
//
|
|
27
|
+
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
|
|
28
|
+
// webdavOpen [configJson] -> { ok, handle, need }
|
|
29
|
+
// webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
|
|
30
|
+
// webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
|
|
31
|
+
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
|
|
32
|
+
// webdavClose [handle] -> { ok }
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
|
|
35
|
+
import { resolve as resolvePath } from 'node:path'
|
|
36
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
37
|
+
import { openLwsStorage } from '../lws/server.mjs'
|
|
38
|
+
|
|
39
|
+
/** The ops this host needs from the engine. */
|
|
40
|
+
export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
|
|
41
|
+
|
|
42
|
+
/** The most engine answers one request may take before this host gives up:
|
|
43
|
+
* a bound against a defective module, not a protocol rule. */
|
|
44
|
+
const MAX_HOPS = 100000
|
|
45
|
+
|
|
46
|
+
/** The most rounds of `need` at start: the engine names two. */
|
|
47
|
+
const MAX_LOADS = 8
|
|
48
|
+
|
|
49
|
+
/** An error of this host, as opposed to one the engine reported. */
|
|
50
|
+
export class WebdavHostError extends Error {
|
|
51
|
+
constructor (message, detail = {}) {
|
|
52
|
+
super(message)
|
|
53
|
+
this.name = 'WebdavHostError'
|
|
54
|
+
this.unknownOp = detail.unknownOp === true
|
|
55
|
+
this.envelope = detail.envelope
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isUnknownOp (error) {
|
|
60
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
61
|
+
return text.indexOf('unknown op') >= 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Random bytes as base64. Pure I/O. */
|
|
65
|
+
function randomBase64 (size) {
|
|
66
|
+
const bytes = new Uint8Array(size)
|
|
67
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
68
|
+
return Buffer.from(bytes).toString('base64')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nowSeconds () {
|
|
72
|
+
return Math.floor(Date.now() / 1000)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One engine call; an `ok: false` envelope is thrown with the engine's words. */
|
|
76
|
+
function callEngine (engine, op, args) {
|
|
77
|
+
let envelope
|
|
78
|
+
try {
|
|
79
|
+
envelope = engine.call(op, args)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isUnknownOp(error)) {
|
|
82
|
+
throw new WebdavHostError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
return envelope
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function blobCall (engine, op, args, bytes) {
|
|
90
|
+
const got = engine.callBlobIO(op, args, bytes)
|
|
91
|
+
const envelope = got.envelope
|
|
92
|
+
if (envelope === null || typeof envelope !== 'object') throw new WebdavHostError(`${op} answered with no envelope`)
|
|
93
|
+
if (envelope.ok === false) throw new WebdavHostError(`${op}: ${envelope.error}`, { envelope })
|
|
94
|
+
return { envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The files and paths the engine named, read and tested. Pure I/O. */
|
|
98
|
+
function gather (configDir, need) {
|
|
99
|
+
const files = (need.read || []).map((name) => {
|
|
100
|
+
try {
|
|
101
|
+
return { name, text: readFileSync(resolvePath(configDir, name), 'utf8') }
|
|
102
|
+
} catch (_error) {
|
|
103
|
+
return { name, missing: true }
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
const exists = (need.exists || []).map((path) => ({ path, present: existsSync(resolvePath(configDir, path)) }))
|
|
107
|
+
return { files, exists }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Open a WebDAV server handle over the mount files of `configDir`.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} options
|
|
114
|
+
* @param {string} options.configDir the directory of mount files
|
|
115
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
116
|
+
* @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function openWebdav (options = {}) {
|
|
119
|
+
if (typeof options.configDir !== 'string') throw new WebdavHostError('openWebdav needs configDir')
|
|
120
|
+
const configDir = options.configDir
|
|
121
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
122
|
+
const listing = readdirSync(configDir)
|
|
123
|
+
let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify({ listing, entropy: randomBase64(32) })])
|
|
124
|
+
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
125
|
+
const handle = envelope.handle
|
|
126
|
+
let loads = 0
|
|
127
|
+
while (envelope.need !== undefined) {
|
|
128
|
+
loads += 1
|
|
129
|
+
if (loads > MAX_LOADS) throw new WebdavHostError('webdavLoad asked for files more often than a start may')
|
|
130
|
+
envelope = callEngine(engine, 'webdavLoad', [handle, JSON.stringify(gather(configDir, envelope.need))])
|
|
131
|
+
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
132
|
+
}
|
|
133
|
+
const ready = envelope.ready
|
|
134
|
+
// A file the engine made (a new key): written once, never over a file.
|
|
135
|
+
for (const w of ready.write || []) {
|
|
136
|
+
writeFileSync(resolvePath(configDir, w.path), w.text, { mode: parseInt(w.mode, 8), flag: 'wx' })
|
|
137
|
+
}
|
|
138
|
+
// Each local storage, opened as lws-serve opens it; the config is the
|
|
139
|
+
// engine's, passed unread.
|
|
140
|
+
const storages = new Map()
|
|
141
|
+
for (const l of ready.local || []) {
|
|
142
|
+
const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true })
|
|
143
|
+
storages.set(l.name, storage)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Carry one `fetch` the engine named. Pure I/O. */
|
|
147
|
+
async function exchange (request, bytes) {
|
|
148
|
+
const init = { method: request.method, headers: request.headers || [], redirect: 'manual' }
|
|
149
|
+
if (bytes.length > 0) init.body = bytes
|
|
150
|
+
let reply
|
|
151
|
+
try {
|
|
152
|
+
reply = await fetch(request.url, init)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { answer: { error: error && error.message ? String(error.message) : String(error) }, bytes: new Uint8Array(0) }
|
|
155
|
+
}
|
|
156
|
+
const headers = []
|
|
157
|
+
reply.headers.forEach((value, name) => { headers.push([name, value]) })
|
|
158
|
+
const body = new Uint8Array(await reply.arrayBuffer())
|
|
159
|
+
return { answer: { status: reply.status, headers, now: nowSeconds(), randomBase64: randomBase64(48) }, bytes: body }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let queue = Promise.resolve()
|
|
163
|
+
|
|
164
|
+
/** One request, start to end. */
|
|
165
|
+
async function serve (request) {
|
|
166
|
+
const record = {
|
|
167
|
+
method: request.method,
|
|
168
|
+
target: request.target,
|
|
169
|
+
headers: request.headers,
|
|
170
|
+
now: nowSeconds(),
|
|
171
|
+
randomBase64: randomBase64(16)
|
|
172
|
+
}
|
|
173
|
+
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
174
|
+
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
175
|
+
const env = got.envelope
|
|
176
|
+
if (env.response !== undefined) {
|
|
177
|
+
return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
|
|
178
|
+
}
|
|
179
|
+
let answer
|
|
180
|
+
let bytes
|
|
181
|
+
if (env.lws !== undefined) {
|
|
182
|
+
const storage = storages.get(env.lws.mount)
|
|
183
|
+
if (storage === undefined) throw new WebdavHostError(`the engine named a storage that is not open: ${env.lws.mount}`)
|
|
184
|
+
const req = env.lws.request
|
|
185
|
+
const a = await storage.step({ method: req.method, target: req.target, headers: req.headers || [], body: got.bytes })
|
|
186
|
+
answer = { status: a.status, headers: a.headers }
|
|
187
|
+
bytes = a.body
|
|
188
|
+
} else if (env.fetch !== undefined) {
|
|
189
|
+
const x = await exchange(env.fetch, got.bytes)
|
|
190
|
+
answer = x.answer
|
|
191
|
+
bytes = x.bytes
|
|
192
|
+
} else {
|
|
193
|
+
throw new WebdavHostError('the engine answered neither response, lws nor fetch')
|
|
194
|
+
}
|
|
195
|
+
got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
|
|
196
|
+
}
|
|
197
|
+
throw new WebdavHostError('a request took more engine answers than this host allows')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
handle,
|
|
202
|
+
engine,
|
|
203
|
+
mounts: ready.mounts || [],
|
|
204
|
+
/**
|
|
205
|
+
* One request, served after the requests before it.
|
|
206
|
+
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
|
|
207
|
+
*/
|
|
208
|
+
step (request) {
|
|
209
|
+
const run = queue.then(() => serve(request))
|
|
210
|
+
queue = run.catch(() => {})
|
|
211
|
+
return run
|
|
212
|
+
},
|
|
213
|
+
close () {
|
|
214
|
+
for (const s of storages.values()) s.close()
|
|
215
|
+
try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readBody (request) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const chunks = []
|
|
223
|
+
request.on('data', (chunk) => chunks.push(chunk))
|
|
224
|
+
request.on('error', reject)
|
|
225
|
+
request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function headerPairs (raw) {
|
|
230
|
+
const pairs = []
|
|
231
|
+
for (let index = 0; index + 1 < raw.length; index += 2) pairs.push([raw[index], raw[index + 1]])
|
|
232
|
+
return pairs
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writeHeaders (response, pairs) {
|
|
236
|
+
for (const pair of pairs) {
|
|
237
|
+
if (!Array.isArray(pair) || pair.length < 2) continue
|
|
238
|
+
const existing = response.getHeader(pair[0])
|
|
239
|
+
if (existing === undefined) response.setHeader(pair[0], String(pair[1]))
|
|
240
|
+
else if (Array.isArray(existing)) response.setHeader(pair[0], existing.concat([String(pair[1])]))
|
|
241
|
+
else response.setHeader(pair[0], [String(existing), String(pair[1])])
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Start the server: open the handle, then listen.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} options `configDir`, `port` (default 8090), `host`
|
|
249
|
+
* (default 127.0.0.1), `engine`, `onError`
|
|
250
|
+
* @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
|
|
251
|
+
*/
|
|
252
|
+
export async function listen (options = {}) {
|
|
253
|
+
const http = await import('node:http')
|
|
254
|
+
const dav = await openWebdav(options)
|
|
255
|
+
const onError = typeof options.onError === 'function'
|
|
256
|
+
? options.onError
|
|
257
|
+
: (error) => { console.error(`webdav-serve: ${error.message}`) }
|
|
258
|
+
const server = http.createServer(async (request, response) => {
|
|
259
|
+
let answer
|
|
260
|
+
try {
|
|
261
|
+
const body = await readBody(request)
|
|
262
|
+
answer = await dav.step({ method: request.method, target: request.url, headers: headerPairs(request.rawHeaders), body })
|
|
263
|
+
} catch (error) {
|
|
264
|
+
onError(error)
|
|
265
|
+
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
266
|
+
response.end(`webdav-serve: ${error.message}\n`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
writeHeaders(response, answer.headers)
|
|
270
|
+
response.statusCode = answer.status
|
|
271
|
+
response.end(answer.body)
|
|
272
|
+
})
|
|
273
|
+
const port = typeof options.port === 'number' ? options.port : 8090
|
|
274
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
275
|
+
await new Promise((resolve, reject) => {
|
|
276
|
+
server.once('error', reject)
|
|
277
|
+
server.listen(port, host, resolve)
|
|
278
|
+
})
|
|
279
|
+
const bound = server.address()
|
|
280
|
+
const origin = `http://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
|
|
281
|
+
return {
|
|
282
|
+
server,
|
|
283
|
+
port: bound.port,
|
|
284
|
+
origin,
|
|
285
|
+
mounts: dav.mounts,
|
|
286
|
+
close () {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
server.close(() => { dav.close(); resolve() })
|
|
289
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|