@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.16.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
+ }