@factoidal/core 0.11.0 → 0.12.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 +382 -0
- package/README.md +63 -3
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/bin/factoidal.mjs +139 -3
- package/browser.d.ts +3 -2
- package/browser.js +1 -2
- package/fn.js +8 -12
- package/index.d.ts +83 -28
- package/index.mjs +1 -2
- 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 +93 -203
- package/lib/dataset.mjs +40 -45
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +15 -10
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +70 -3
- package/lws/persist.mjs +237 -6
- package/lws/server.mjs +271 -9
- package/package.json +2 -1
- package/rdfjs.mjs +4 -2
- package/solid/server/index.mjs +7 -1
package/lws/persist.mjs
CHANGED
|
@@ -11,15 +11,83 @@
|
|
|
11
11
|
// DIR/lws-state.json the snapshot, replaced atomically
|
|
12
12
|
//
|
|
13
13
|
// No request target ever becomes a file name here, so a request cannot
|
|
14
|
-
// address a file outside DIR.
|
|
14
|
+
// address a file outside DIR.
|
|
15
|
+
//
|
|
16
|
+
// ENCRYPTION AT REST (https://github.com/danbri/factoidal/issues/921)
|
|
17
|
+
// This file also reads the environment variable FACTOIDAL_LWS_KEY and
|
|
18
|
+
// draws 12 random bytes for every write. Both go to the engine unread: the
|
|
19
|
+
// key text as the open configuration's `storageKey`, the bytes as the
|
|
20
|
+
// `nonce` of the snapshot call. The engine decides everything else
|
|
21
|
+
// (`L4Factoidal/LWS/SnapshotSeal.lean`): whether the key is valid, whether
|
|
22
|
+
// the file in DIR is sealed, whether it authenticates, and the bytes of
|
|
23
|
+
// the sealed document. This file never inspects the document it writes. The write is: a temporary file in DIR,
|
|
15
24
|
// fsync, rename over the state file, fsync of DIR. A crash leaves the old
|
|
16
25
|
// snapshot or the new one, never a torn one.
|
|
17
26
|
|
|
18
|
-
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
|
|
19
|
-
import { join, resolve } from 'node:path'
|
|
27
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
|
|
28
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
20
29
|
|
|
21
30
|
export const STATE_FILE = 'lws-state.json'
|
|
22
31
|
|
|
32
|
+
/** The environment variable that holds the storage key. */
|
|
33
|
+
export const STORAGE_KEY_VARIABLE = 'FACTOIDAL_LWS_KEY'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The text of the storage key: `explicit` when the caller gave a string,
|
|
37
|
+
* none when the caller gave `null`, and otherwise the environment variable
|
|
38
|
+
* (Node `process.env`, or `Deno.env` when only Deno's API is present). An
|
|
39
|
+
* empty value is no key. The engine checks the text.
|
|
40
|
+
*/
|
|
41
|
+
export function storageKeyText (explicit) {
|
|
42
|
+
if (typeof explicit === 'string') return explicit
|
|
43
|
+
if (explicit === null) return undefined
|
|
44
|
+
let value
|
|
45
|
+
try {
|
|
46
|
+
if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
|
|
47
|
+
value = globalThis.process.env[STORAGE_KEY_VARIABLE]
|
|
48
|
+
} else if (globalThis.Deno !== undefined) {
|
|
49
|
+
value = globalThis.Deno.env.get(STORAGE_KEY_VARIABLE)
|
|
50
|
+
}
|
|
51
|
+
} catch (_error) {
|
|
52
|
+
value = undefined
|
|
53
|
+
}
|
|
54
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The environment variable that sets where `lws-serve DIR` keeps the
|
|
58
|
+
* storage when the caller names no `storage` (issue 918). */
|
|
59
|
+
export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The text of the storage mode: `explicit` when the caller gave a string,
|
|
63
|
+
* and otherwise the environment variable, passed unread; the engine checks
|
|
64
|
+
* it. An empty value is none (the engine's default, the snapshot).
|
|
65
|
+
*/
|
|
66
|
+
export function storageModeText (explicit) {
|
|
67
|
+
if (typeof explicit === 'string') return explicit
|
|
68
|
+
let value
|
|
69
|
+
try {
|
|
70
|
+
if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
|
|
71
|
+
value = globalThis.process.env[STORAGE_MODE_VARIABLE]
|
|
72
|
+
} else if (globalThis.Deno !== undefined) {
|
|
73
|
+
value = globalThis.Deno.env.get(STORAGE_MODE_VARIABLE)
|
|
74
|
+
}
|
|
75
|
+
} catch (_error) {
|
|
76
|
+
value = undefined
|
|
77
|
+
}
|
|
78
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 12 bytes from the platform's random source, base64: the nonce of one
|
|
82
|
+
* snapshot write. Pure I/O. */
|
|
83
|
+
function nonceBase64 () {
|
|
84
|
+
const bytes = new Uint8Array(12)
|
|
85
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
86
|
+
let text = ''
|
|
87
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
88
|
+
return btoa(text)
|
|
89
|
+
}
|
|
90
|
+
|
|
23
91
|
/** The absolute path of the state file of a storage directory. */
|
|
24
92
|
export function stateFileOf (dir) {
|
|
25
93
|
return join(resolve(dir), STATE_FILE)
|
|
@@ -37,7 +105,7 @@ export function readSnapshot (dir) {
|
|
|
37
105
|
try {
|
|
38
106
|
return JSON.parse(text)
|
|
39
107
|
} catch (error) {
|
|
40
|
-
throw new Error(`${file} is not JSON: ${error.message}`)
|
|
108
|
+
throw Object.assign(new Error(`${file} is not JSON: ${error.message}`), { stateFile: true })
|
|
41
109
|
}
|
|
42
110
|
}
|
|
43
111
|
|
|
@@ -65,14 +133,177 @@ export function writeSnapshot (dir, snapshot) {
|
|
|
65
133
|
|
|
66
134
|
/**
|
|
67
135
|
* After one step: when the engine says the storage changed, ask it for
|
|
68
|
-
* the snapshot (`snapshotOp`) and write it.
|
|
136
|
+
* the snapshot (`snapshotOp`) and write it. The call carries a fresh random
|
|
137
|
+
* nonce; the engine uses it when the storage is sealed under a key.
|
|
69
138
|
*/
|
|
70
139
|
export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
|
|
71
140
|
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
72
141
|
if (envelope.changed !== true) return
|
|
73
|
-
const answer = engine.call(snapshotOp, [handle])
|
|
142
|
+
const answer = engine.call(snapshotOp, [handle, JSON.stringify({ nonce: nonceBase64() })])
|
|
74
143
|
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
75
144
|
throw new Error(`${snapshotOp} answered with no snapshot`)
|
|
76
145
|
}
|
|
77
146
|
writeSnapshot(dir, answer.snapshot)
|
|
78
147
|
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// A STORAGE KEPT IN FILES (https://github.com/danbri/factoidal/issues/918)
|
|
151
|
+
//
|
|
152
|
+
// With the open configuration `storage: "files"` the engine decides the
|
|
153
|
+
// directory layout and every byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean,
|
|
154
|
+
// design record docs/designissues/2026-10-22-lws-storage-on-disk.md). This
|
|
155
|
+
// part of the file only performs what the engine names:
|
|
156
|
+
//
|
|
157
|
+
// * reads: the engine names files (paths relative to DIR); each is read
|
|
158
|
+
// whole, or reported absent;
|
|
159
|
+
// * a plan of file operations, in order, with their bytes in one region:
|
|
160
|
+
// write {path, temp, offset, length} parent directory, temp file,
|
|
161
|
+
// fsync, rename, fsync directory
|
|
162
|
+
// append {path, offset, length} append, fsync
|
|
163
|
+
// remove {path} remove; a missing file is no error
|
|
164
|
+
// rename {from, to} rename, fsync directory; a missing
|
|
165
|
+
// file is no error
|
|
166
|
+
//
|
|
167
|
+
// A name that leaves DIR is refused as a failure of this host; the engine
|
|
168
|
+
// never names one.
|
|
169
|
+
|
|
170
|
+
/** The absolute path of a name the engine gave, inside DIR. */
|
|
171
|
+
function insideDir (root, name) {
|
|
172
|
+
if (typeof name !== 'string' || name === '') throw new Error('lws files: the engine named no file')
|
|
173
|
+
const abs = resolve(root, name)
|
|
174
|
+
if (!abs.startsWith(root + sep)) throw new Error(`lws files: ${name} is outside the storage directory`)
|
|
175
|
+
return abs
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** fsync a directory; some platforms cannot, and the rename stands. */
|
|
179
|
+
function fsyncDir (dir) {
|
|
180
|
+
try {
|
|
181
|
+
const fd = openSync(dir, 'r')
|
|
182
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
183
|
+
} catch (_error) {
|
|
184
|
+
// Some platforms cannot fsync a directory.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `seed` for one engine call: 32 random bytes, base64. Pure I/O. */
|
|
189
|
+
function seedBase64 () {
|
|
190
|
+
const bytes = new Uint8Array(32)
|
|
191
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
192
|
+
let text = ''
|
|
193
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
194
|
+
return btoa(text)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read the files the engine named. Returns the `files` member of the load
|
|
199
|
+
* document and the bytes of the present files, one after the other.
|
|
200
|
+
*/
|
|
201
|
+
export function readNamedFiles (dir, names) {
|
|
202
|
+
const root = resolve(dir)
|
|
203
|
+
const files = []
|
|
204
|
+
const parts = []
|
|
205
|
+
let total = 0
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
const abs = insideDir(root, name)
|
|
208
|
+
if (!existsSync(abs)) {
|
|
209
|
+
files.push({ path: name, present: false })
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const bytes = readFileSync(abs)
|
|
213
|
+
files.push({ path: name, present: true, length: bytes.length })
|
|
214
|
+
parts.push(bytes)
|
|
215
|
+
total += bytes.length
|
|
216
|
+
}
|
|
217
|
+
const region = new Uint8Array(total)
|
|
218
|
+
let at = 0
|
|
219
|
+
for (const part of parts) { region.set(part, at); at += part.length }
|
|
220
|
+
return { files, region }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Perform a plan of file operations exactly as the engine gave it. */
|
|
224
|
+
export function performPlan (dir, plan, bytes) {
|
|
225
|
+
const root = resolve(dir)
|
|
226
|
+
mkdirSync(root, { recursive: true })
|
|
227
|
+
const slice = (op) => bytes.subarray(op.offset, op.offset + op.length)
|
|
228
|
+
for (const op of Array.isArray(plan) ? plan : []) {
|
|
229
|
+
switch (op.op) {
|
|
230
|
+
case 'write': {
|
|
231
|
+
const path = insideDir(root, op.path)
|
|
232
|
+
const temp = insideDir(root, op.temp)
|
|
233
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
234
|
+
const fd = openSync(temp, 'w', 0o600)
|
|
235
|
+
try {
|
|
236
|
+
writeSync(fd, slice(op))
|
|
237
|
+
fsyncSync(fd)
|
|
238
|
+
} finally {
|
|
239
|
+
closeSync(fd)
|
|
240
|
+
}
|
|
241
|
+
renameSync(temp, path)
|
|
242
|
+
fsyncDir(dirname(path))
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
case 'append': {
|
|
246
|
+
const path = insideDir(root, op.path)
|
|
247
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
248
|
+
const fd = openSync(path, 'a', 0o600)
|
|
249
|
+
try {
|
|
250
|
+
writeSync(fd, slice(op))
|
|
251
|
+
fsyncSync(fd)
|
|
252
|
+
} finally {
|
|
253
|
+
closeSync(fd)
|
|
254
|
+
}
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
case 'remove': {
|
|
258
|
+
try { unlinkSync(insideDir(root, op.path)) } catch (error) {
|
|
259
|
+
if (error.code !== 'ENOENT') throw error
|
|
260
|
+
}
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
case 'rename': {
|
|
264
|
+
const to = insideDir(root, op.to)
|
|
265
|
+
try { renameSync(insideDir(root, op.from), to) } catch (error) {
|
|
266
|
+
if (error.code !== 'ENOENT') throw error
|
|
267
|
+
}
|
|
268
|
+
fsyncDir(dirname(to))
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
default:
|
|
272
|
+
throw new Error(`lws files: the engine named an unknown file operation ${String(op.op)}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Open a storage kept in files: read what `lwsOpen` named, give it to
|
|
279
|
+
* `lwsLoad`, read what that names, until the engine is ready; then perform
|
|
280
|
+
* the plan of the start. An engine refusal is thrown as the engine's error.
|
|
281
|
+
*/
|
|
282
|
+
export function loadFiles (engine, handle, opened, dir) {
|
|
283
|
+
let want = opened.load
|
|
284
|
+
for (let round = 0; round < 8; round += 1) {
|
|
285
|
+
const { files, region } = readNamedFiles(dir, Array.isArray(want.read) ? want.read : [])
|
|
286
|
+
const answered = engine.callBlobIO('lwsLoad', [handle, JSON.stringify({ files, seed: seedBase64() })], region)
|
|
287
|
+
const envelope = answered.envelope
|
|
288
|
+
if (envelope.ready === true) {
|
|
289
|
+
performPlan(dir, envelope.plan, answered.bytes)
|
|
290
|
+
return envelope
|
|
291
|
+
}
|
|
292
|
+
if (envelope.load === undefined || envelope.load === null) {
|
|
293
|
+
throw new Error('lwsLoad answered neither load nor ready')
|
|
294
|
+
}
|
|
295
|
+
want = envelope.load
|
|
296
|
+
}
|
|
297
|
+
throw new Error('lwsLoad asked for more reads than one start may make')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* After one step of a storage kept in files: when the engine says the
|
|
302
|
+
* storage changed, ask it for the write plan (`lwsPersist`) and perform it.
|
|
303
|
+
*/
|
|
304
|
+
export function persistFilesIfChanged (engine, handle, envelope, dir) {
|
|
305
|
+
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
306
|
+
if (envelope.changed !== true) return
|
|
307
|
+
const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
|
|
308
|
+
performPlan(dir, answered.envelope.plan, answered.bytes)
|
|
309
|
+
}
|
package/lws/server.mjs
CHANGED
|
@@ -22,6 +22,29 @@
|
|
|
22
22
|
// lwsStep [handle, requestJson] -> { ok, response }
|
|
23
23
|
// lwsClose [handle] -> { ok }
|
|
24
24
|
// lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
|
|
25
|
+
// lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
|
|
26
|
+
// lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
|
|
27
|
+
//
|
|
28
|
+
// RATE LIMIT AND INBOX CHECK (https://github.com/danbri/factoidal/issues/915)
|
|
29
|
+
// An answer may carry `wake: SECONDS`: deliveries wait for a rate-limit
|
|
30
|
+
// window. This host calls `lwsFlush` after that many seconds and makes the
|
|
31
|
+
// deliveries it names. `lwsStep` may answer `{ ok, resolve: { host } }`:
|
|
32
|
+
// this host resolves the name (`dns.lookup`, every address) and steps the
|
|
33
|
+
// request again with `resolved: [{ host, addresses }]`. The listen address,
|
|
34
|
+
// port and the machine's interface addresses go to `lwsOpen` as `listen`.
|
|
35
|
+
// Whether an inbox is this server, and how many deliveries go when, are
|
|
36
|
+
// decided by `L4Factoidal/LWS/InboxGuard.lean` and `Notifications.lean`.
|
|
37
|
+
//
|
|
38
|
+
// NOTIFICATIONS (https://github.com/danbri/factoidal/issues/890)
|
|
39
|
+
// `lwsStep` may carry `deliveries: [{ id, url, headers, body }]`: HTTP
|
|
40
|
+
// requests the engine decided to make (LWS notifications, webhook suite).
|
|
41
|
+
// This host POSTs each one exactly as given, after the response to the
|
|
42
|
+
// request that caused it, and reports the status it got (0 when no answer
|
|
43
|
+
// came) to `lwsDelivered`. When the engine answers `retry`, this host
|
|
44
|
+
// waits the number of seconds it names and makes that request. Which
|
|
45
|
+
// changes are announced, to whom, with which body and signature, whether
|
|
46
|
+
// to retry and when a subscription ends are all decided by
|
|
47
|
+
// `formal/lean4/L4Factoidal/LWS/Notifications.lean`.
|
|
25
48
|
//
|
|
26
49
|
// `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
|
|
27
50
|
// response: the engine needs an authorization server's metadata or key
|
|
@@ -38,7 +61,7 @@
|
|
|
38
61
|
// region, with no encoding and no decoding here. This host never reads a
|
|
39
62
|
// body as text.
|
|
40
63
|
//
|
|
41
|
-
// The config members are `baseIri`, `now`, `owner`, `agent`, `rootAcl`,
|
|
64
|
+
// The config members are `baseIri`, `now`, `owner`, `agent`, `pageSize`, `rootAcl`,
|
|
42
65
|
// `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
|
|
43
66
|
// Node's `request.url` through unchanged and never composes an absolute
|
|
44
67
|
// IRI: `baseIri` tells the engine what the root maps to.
|
|
@@ -61,10 +84,27 @@
|
|
|
61
84
|
// produced and this code keeps working unchanged.
|
|
62
85
|
|
|
63
86
|
import { loadEngine } from '../bin/engine.mjs'
|
|
64
|
-
import { readSnapshot, persistIfChanged } from './persist.mjs'
|
|
87
|
+
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged } from './persist.mjs'
|
|
65
88
|
|
|
66
89
|
/** The ops this host needs from the engine. */
|
|
67
|
-
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched'
|
|
90
|
+
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
|
|
91
|
+
'lwsLoad', 'lwsPersist']
|
|
92
|
+
|
|
93
|
+
/** How long this host waits for the answer to one delivery request before
|
|
94
|
+
* it reports "no answer" (status 0) to the engine. */
|
|
95
|
+
const DELIVERY_TIMEOUT_MS = 10000
|
|
96
|
+
|
|
97
|
+
/** Random bytes for the engine, base64: `entropy` at open (32 bytes, the
|
|
98
|
+
* seed of the signing key of a new storage and of the identifiers of one
|
|
99
|
+
* run) and `random` with each request (16 bytes, the name of a search
|
|
100
|
+
* results resource when the engine makes one). Pure I/O. */
|
|
101
|
+
function entropyBase64 (size = 32) {
|
|
102
|
+
const bytes = new Uint8Array(size)
|
|
103
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
104
|
+
let text = ''
|
|
105
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
106
|
+
return btoa(text)
|
|
107
|
+
}
|
|
68
108
|
|
|
69
109
|
/** The most documents one request may make this host fetch before the
|
|
70
110
|
* engine has answered it. The engine names at most two per issuer (its
|
|
@@ -72,6 +112,32 @@ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFet
|
|
|
72
112
|
* from looping the host. */
|
|
73
113
|
const MAX_FETCHES = 8
|
|
74
114
|
|
|
115
|
+
/** The most host names one request may make this host resolve. The engine
|
|
116
|
+
* names at most one (the inbox of a subscription request). */
|
|
117
|
+
const MAX_RESOLVES = 4
|
|
118
|
+
|
|
119
|
+
/** Resolve one host name the engine named. Pure I/O: every address
|
|
120
|
+
* `dns.lookup` gives, or none when it fails; the engine decides. */
|
|
121
|
+
async function resolveHost (host) {
|
|
122
|
+
try {
|
|
123
|
+
const dns = await import('node:dns')
|
|
124
|
+
const found = await dns.promises.lookup(host, { all: true, verbatim: true })
|
|
125
|
+
return found.map((a) => a.address)
|
|
126
|
+
} catch (_error) {
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The addresses of this machine's network interfaces. Pure I/O. */
|
|
132
|
+
async function interfaceAddresses () {
|
|
133
|
+
try {
|
|
134
|
+
const os = await import('node:os')
|
|
135
|
+
return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
|
|
136
|
+
} catch (_error) {
|
|
137
|
+
return []
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
75
141
|
/**
|
|
76
142
|
* Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
|
|
77
143
|
* engine decides what the bytes mean. A network failure is reported as
|
|
@@ -169,7 +235,7 @@ function contentBytes (request) {
|
|
|
169
235
|
* Open one LWS storage on the engine and return the handle plus the two
|
|
170
236
|
* calls a host makes against it.
|
|
171
237
|
*
|
|
172
|
-
* The config members are the ABI's own: `baseIri`, `now`, `owner`,
|
|
238
|
+
* The config members are the ABI's own: `baseIri`, `now`, `owner`, `pageSize`,
|
|
173
239
|
* `agent`. Nothing else is sent.
|
|
174
240
|
*
|
|
175
241
|
* @param {object} options
|
|
@@ -177,7 +243,15 @@ function contentBytes (request) {
|
|
|
177
243
|
* @param {number} [options.now] the engine clock, seconds since the epoch
|
|
178
244
|
* @param {string} [options.owner] the WebID the storage advertises
|
|
179
245
|
* @param {string} [options.agent] the WebID of the requesting agent
|
|
246
|
+
* @param {number} [options.pageSize] the container pagination threshold and page size (default 100; 0 turns pagination off)
|
|
247
|
+
* @param {boolean} [options.paginateAlways] present every container listing as paginated (default false)
|
|
248
|
+
* @param {boolean|string} [options.moves] which resources move: true or "all", "data", false or "off" (default all)
|
|
249
|
+
* @param {boolean} [options.linksetPut] does a linkset accept PUT (default true)
|
|
180
250
|
* @param {boolean} [options.realClock] stamp each request with this host's clock
|
|
251
|
+
* @param {string|null} [options.storageKey] the storage key text (64 hex digits or base64url);
|
|
252
|
+
* default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
|
|
253
|
+
* @param {string} [options.storage] where DIR keeps the storage: "snapshot" (default, one
|
|
254
|
+
* file) or "files" (one file per body and a journal, issue 918); passed unread
|
|
181
255
|
* @param {object} [options.engine] an engine handle to reuse
|
|
182
256
|
* @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
|
|
183
257
|
*/
|
|
@@ -188,6 +262,9 @@ export async function openLwsStorage (options = {}) {
|
|
|
188
262
|
if (typeof options.now === 'number') config.now = options.now
|
|
189
263
|
if (typeof options.owner === 'string') config.owner = options.owner
|
|
190
264
|
if (typeof options.agent === 'string') config.agent = options.agent
|
|
265
|
+
// The pagination threshold and page size of container listings; the
|
|
266
|
+
// engine decides what it means (L4Factoidal/LWS/Pagination.lean).
|
|
267
|
+
if (typeof options.pageSize === 'number') config.pageSize = options.pageSize
|
|
191
268
|
// Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
|
|
192
269
|
// through unread; `insecureNoAuth` is the explicit development mode.
|
|
193
270
|
// With neither, the engine enforces authorization with no trusted
|
|
@@ -195,10 +272,66 @@ export async function openLwsStorage (options = {}) {
|
|
|
195
272
|
if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
|
|
196
273
|
if (options.insecureNoAuth === true) config.insecureNoAuth = true
|
|
197
274
|
if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
|
|
275
|
+
// Notifications are on unless the caller turns them off; the engine
|
|
276
|
+
// decides everything else about them.
|
|
277
|
+
if (options.notifications === false) config.notifications = false
|
|
278
|
+
// The Type Index and Type Search services are on unless the caller turns
|
|
279
|
+
// them off (L4Factoidal/LWS/Search.lean).
|
|
280
|
+
if (options.typeIndex === false) config.typeIndex = false
|
|
281
|
+
// Moves (a change of the up link, L4Factoidal/LWS/Move.lean) are on
|
|
282
|
+
// unless the caller turns them off.
|
|
283
|
+
if (options.moves === false) config.moves = false
|
|
284
|
+
// `moves` may also be "all", "data" or "off", passed unread; the engine
|
|
285
|
+
// decides what each means and refuses another value (Wasm/Ops/Lws.lean).
|
|
286
|
+
if (typeof options.moves === 'string') config.moves = options.moves
|
|
287
|
+
// A linkset resource accepts PUT unless the caller turns it off
|
|
288
|
+
// (L4Factoidal/LWS/Operations.lean, linksetStep).
|
|
289
|
+
if (options.linksetPut === false) config.linksetPut = false
|
|
290
|
+
// Every container listing paginated, also one that fits on one page
|
|
291
|
+
// (L4Factoidal/LWS/Pagination.lean, paginatedWith). Off unless turned on.
|
|
292
|
+
if (options.paginateAlways === true) config.paginateAlways = true
|
|
293
|
+
// Access requests and grants are on unless the caller turns them off
|
|
294
|
+
// (L4Factoidal/LWS/AccessGrants.lean).
|
|
295
|
+
if (options.accessGrants === false) config.accessGrants = false
|
|
296
|
+
// ANTICIPATORY AND UNSTABLE (https://github.com/danbri/factoidal/issues/922):
|
|
297
|
+
// a QUERY answer of the type search names a results resource with
|
|
298
|
+
// Content-Location. Off unless the caller turns it on
|
|
299
|
+
// (L4Factoidal/LWS/SearchResults.lean decides everything about it).
|
|
300
|
+
if (options.searchResults === true) config.searchResults = true
|
|
301
|
+
if (typeof options.searchResultsTtl === 'number') config.searchResultsTtl = options.searchResultsTtl
|
|
302
|
+
if (typeof options.searchResultsMax === 'number') config.searchResultsMax = options.searchResultsMax
|
|
303
|
+
// Merkle hashes (https://github.com/danbri/factoidal/issues/919): off
|
|
304
|
+
// unless the caller turns them on (L4Factoidal/LWS/MerkleHttp.lean decides
|
|
305
|
+
// every field and member they add).
|
|
306
|
+
if (options.merkle === true) config.merkle = true
|
|
307
|
+
// Webhook delivery rate (issue 915), the redirect table bound (issue 914)
|
|
308
|
+
// and the storage controller's inbox (issue 920): passed unread; the
|
|
309
|
+
// engine checks and decides.
|
|
310
|
+
if (typeof options.deliveryLimit === 'number') config.deliveryLimit = options.deliveryLimit
|
|
311
|
+
if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
|
|
312
|
+
if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
|
|
313
|
+
if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
|
|
314
|
+
// What this host bound: data for the engine's inbox check (issue 915).
|
|
315
|
+
if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
|
|
316
|
+
config.entropy = entropyBase64()
|
|
317
|
+
// Encryption at rest (issue 921): the key text from `storageKey` or the
|
|
318
|
+
// environment variable FACTOIDAL_LWS_KEY, passed unread. The engine checks
|
|
319
|
+
// it, refuses a file that does not match it, and seals every snapshot.
|
|
320
|
+
const storageKey = storageKeyText(options.storageKey)
|
|
321
|
+
if (storageKey !== undefined) config.storageKey = storageKey
|
|
322
|
+
// Where the storage is kept (https://github.com/danbri/factoidal/issues/918):
|
|
323
|
+
// `storage` is passed unread; the engine refuses a value it does not know.
|
|
324
|
+
// With "files" the engine names the files to read (`load`) and gives the
|
|
325
|
+
// plan of every write (`lwsPersist`), and this host reads no snapshot.
|
|
326
|
+
// With a DIR and no `storage` option, the environment variable
|
|
327
|
+
// FACTOIDAL_LWS_STORAGE is the value; without a DIR there is nothing to
|
|
328
|
+
// keep, and only an explicit option is passed.
|
|
329
|
+
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
330
|
+
const storage = dir !== null ? storageModeText(options.storage) : options.storage
|
|
331
|
+
if (typeof storage === 'string') config.storage = storage
|
|
198
332
|
// `dir` names the storage directory. The snapshot found there, if any,
|
|
199
333
|
// is the storage to continue from; the engine checks it.
|
|
200
|
-
|
|
201
|
-
if (dir !== null) {
|
|
334
|
+
if (dir !== null && storage !== 'files') {
|
|
202
335
|
const snapshot = readSnapshot(dir)
|
|
203
336
|
if (snapshot !== null) config.snapshot = snapshot
|
|
204
337
|
}
|
|
@@ -217,6 +350,95 @@ export async function openLwsStorage (options = {}) {
|
|
|
217
350
|
if (handle === null) {
|
|
218
351
|
throw new LwsHostError('lwsOpen answered with no handle')
|
|
219
352
|
}
|
|
353
|
+
// A storage kept in files: read the files the engine names until it is
|
|
354
|
+
// ready, and perform the plan of the start. Without a DIR, there are no
|
|
355
|
+
// files: the engine is told none is present.
|
|
356
|
+
const filesMode = opened !== null && typeof opened === 'object' &&
|
|
357
|
+
opened.load !== undefined && opened.load !== null
|
|
358
|
+
if (filesMode) {
|
|
359
|
+
if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
|
|
360
|
+
loadFiles(engine, handle, opened, dir)
|
|
361
|
+
}
|
|
362
|
+
/** After a call whose envelope may say `changed`: write what the engine
|
|
363
|
+
* gives (the snapshot, or the plan of a storage kept in files). */
|
|
364
|
+
const persist = (envelope) => {
|
|
365
|
+
if (dir === null) return
|
|
366
|
+
if (filesMode) persistFilesIfChanged(engine, handle, envelope, dir)
|
|
367
|
+
else persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Deliveries in flight, so a caller (a test, a shutdown) can wait.
|
|
371
|
+
const inFlight = new Set()
|
|
372
|
+
let closed = false
|
|
373
|
+
// The one timer that calls `lwsFlush` when the engine named a `wake`.
|
|
374
|
+
let flushTimer = null
|
|
375
|
+
let flushAt = Infinity
|
|
376
|
+
const clockNow = () => (options.realClock === true ? { now: Math.floor(Date.now() / 1000) } : {})
|
|
377
|
+
|
|
378
|
+
/** Make one delivery request the engine named, report the status, and
|
|
379
|
+
* follow the engine's answer. */
|
|
380
|
+
function deliver (delivery) {
|
|
381
|
+
const job = (async () => {
|
|
382
|
+
let status = 0
|
|
383
|
+
try {
|
|
384
|
+
const answer = await fetch(delivery.url, {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: Array.isArray(delivery.headers) ? delivery.headers : [],
|
|
387
|
+
body: delivery.body,
|
|
388
|
+
redirect: 'manual',
|
|
389
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
|
|
390
|
+
})
|
|
391
|
+
status = answer.status
|
|
392
|
+
await answer.arrayBuffer()
|
|
393
|
+
} catch (_error) {
|
|
394
|
+
status = 0
|
|
395
|
+
}
|
|
396
|
+
if (closed) return
|
|
397
|
+
const told = engine.call('lwsDelivered', [handle, JSON.stringify({
|
|
398
|
+
id: delivery.id, status, ...clockNow()
|
|
399
|
+
})])
|
|
400
|
+
persist(told)
|
|
401
|
+
const retry = told !== null && typeof told === 'object' ? told.retry : null
|
|
402
|
+
if (retry !== undefined && retry !== null && typeof retry.after === 'number') {
|
|
403
|
+
await new Promise((resolve) => {
|
|
404
|
+
const timer = setTimeout(resolve, retry.after * 1000)
|
|
405
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
406
|
+
})
|
|
407
|
+
if (!closed) deliver(retry.delivery)
|
|
408
|
+
}
|
|
409
|
+
})()
|
|
410
|
+
inFlight.add(job)
|
|
411
|
+
// A failure here is a failure of this host or of the engine call, not a
|
|
412
|
+
// protocol answer; it is reported and the delivery is dropped.
|
|
413
|
+
job.catch((error) => { console.error(`lws-serve: delivery: ${error && error.message ? error.message : error}`) })
|
|
414
|
+
.finally(() => inFlight.delete(job))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Call `lwsFlush` after `seconds`, unless an earlier call is due. */
|
|
418
|
+
function scheduleFlush (seconds) {
|
|
419
|
+
if (closed || typeof seconds !== 'number') return
|
|
420
|
+
const at = Date.now() + seconds * 1000
|
|
421
|
+
if (flushTimer !== null && flushAt <= at) return
|
|
422
|
+
if (flushTimer !== null) clearTimeout(flushTimer)
|
|
423
|
+
flushAt = at
|
|
424
|
+
flushTimer = setTimeout(() => {
|
|
425
|
+
flushTimer = null
|
|
426
|
+
flushAt = Infinity
|
|
427
|
+
if (closed) return
|
|
428
|
+
try {
|
|
429
|
+
const told = engine.call('lwsFlush', [handle, JSON.stringify(clockNow())])
|
|
430
|
+
persist(told)
|
|
431
|
+
if (told !== null && typeof told === 'object') {
|
|
432
|
+
if (Array.isArray(told.deliveries)) for (const delivery of told.deliveries) deliver(delivery)
|
|
433
|
+
if (typeof told.wake === 'number') scheduleFlush(told.wake)
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error(`lws-serve: flush: ${error && error.message ? error.message : error}`)
|
|
437
|
+
}
|
|
438
|
+
}, seconds * 1000)
|
|
439
|
+
if (flushTimer && typeof flushTimer.unref === 'function') flushTimer.unref()
|
|
440
|
+
}
|
|
441
|
+
|
|
220
442
|
return {
|
|
221
443
|
engine,
|
|
222
444
|
handle,
|
|
@@ -229,12 +451,30 @@ export async function openLwsStorage (options = {}) {
|
|
|
229
451
|
async step (request) {
|
|
230
452
|
const content = contentBytes(request)
|
|
231
453
|
const { body: _body, ...fields } = request
|
|
454
|
+
// Random bytes with every request; the engine uses them only to name
|
|
455
|
+
// a search results resource (issue 922), and only when that is on.
|
|
456
|
+
const stamped = { ...fields, random: entropyBase64(16) }
|
|
232
457
|
const record = options.realClock === true
|
|
233
|
-
? { ...
|
|
234
|
-
:
|
|
458
|
+
? { ...stamped, now: Math.floor(Date.now() / 1000) }
|
|
459
|
+
: stamped
|
|
460
|
+
// The engine may ask for the addresses of a host name first
|
|
461
|
+
// (`resolve`); this host resolves it and steps the request again.
|
|
462
|
+
let resolves = 0
|
|
235
463
|
const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
|
|
236
464
|
let answered = callStep()
|
|
237
465
|
let envelope = answered.envelope
|
|
466
|
+
while (envelope !== null && typeof envelope === 'object' &&
|
|
467
|
+
envelope.resolve !== undefined && envelope.resolve !== null) {
|
|
468
|
+
resolves += 1
|
|
469
|
+
if (resolves > MAX_RESOLVES) {
|
|
470
|
+
throw new LwsHostError('the engine asked for more host names than one request may resolve')
|
|
471
|
+
}
|
|
472
|
+
const host = String(envelope.resolve.host)
|
|
473
|
+
const addresses = await resolveHost(host)
|
|
474
|
+
record.resolved = [...(Array.isArray(record.resolved) ? record.resolved : []), { host, addresses }]
|
|
475
|
+
answered = callStep()
|
|
476
|
+
envelope = answered.envelope
|
|
477
|
+
}
|
|
238
478
|
// The engine may answer "fetch this document first" (an issuer's
|
|
239
479
|
// metadata or key set). The host fetches exactly what it is told,
|
|
240
480
|
// hands the bytes back, and steps the request again.
|
|
@@ -258,10 +498,23 @@ export async function openLwsStorage (options = {}) {
|
|
|
258
498
|
envelope = answered.envelope
|
|
259
499
|
}
|
|
260
500
|
const answer = responseOf(envelope, answered.bytes)
|
|
261
|
-
|
|
501
|
+
persist(envelope)
|
|
502
|
+
if (Array.isArray(envelope.deliveries)) {
|
|
503
|
+
// After this step's answer is ready; the engine's state already
|
|
504
|
+
// records them as pending.
|
|
505
|
+
for (const delivery of envelope.deliveries) setTimeout(() => deliver(delivery), 0)
|
|
506
|
+
}
|
|
507
|
+
if (typeof envelope.wake === 'number') scheduleFlush(envelope.wake)
|
|
262
508
|
return answer
|
|
263
509
|
},
|
|
510
|
+
/** Resolves when no delivery request is in flight. */
|
|
511
|
+
async settled () {
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
513
|
+
while (inFlight.size > 0) await Promise.allSettled([...inFlight])
|
|
514
|
+
},
|
|
264
515
|
close () {
|
|
516
|
+
closed = true
|
|
517
|
+
if (flushTimer !== null) clearTimeout(flushTimer)
|
|
265
518
|
try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
|
|
266
519
|
}
|
|
267
520
|
}
|
|
@@ -359,6 +612,14 @@ export async function createLwsServer (options = {}) {
|
|
|
359
612
|
}
|
|
360
613
|
settings = { ...settings, baseIri: value }
|
|
361
614
|
},
|
|
615
|
+
/** Pass what the socket bound (address, port, interface addresses),
|
|
616
|
+
* before the first request. Data for the engine's inbox check. */
|
|
617
|
+
setListen (value) {
|
|
618
|
+
if (opening !== null) {
|
|
619
|
+
throw new LwsHostError('the storage is already open; set listen first')
|
|
620
|
+
}
|
|
621
|
+
settings = { ...settings, listen: value }
|
|
622
|
+
},
|
|
362
623
|
close () {
|
|
363
624
|
return new Promise((resolve) => {
|
|
364
625
|
server.close(() => {
|
|
@@ -406,6 +667,7 @@ export async function listen (options = {}) {
|
|
|
406
667
|
const bound = created.server.address()
|
|
407
668
|
const origin = `http://${host}:${bound.port}`
|
|
408
669
|
if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
|
|
670
|
+
created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
|
|
409
671
|
try {
|
|
410
672
|
await created.storage()
|
|
411
673
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -135,6 +135,7 @@
|
|
|
135
135
|
"build": "bash ../../formal/lean4/Wasm/build-wasm.sh",
|
|
136
136
|
"test": "node --test 'test/*.test.js' 'test/*.test.mjs'",
|
|
137
137
|
"test:speed": "node ../../tests/perf/npm_speed_gate.mjs",
|
|
138
|
+
"test:costs": "node ../../tests/perf/npm_costs.mjs",
|
|
138
139
|
"prepublishOnly": "npm test"
|
|
139
140
|
},
|
|
140
141
|
"devDependencies": {
|