@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/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. The write is: a temporary file in 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
- const dir = typeof options.dir === 'string' ? options.dir : null
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
- ? { ...fields, now: Math.floor(Date.now() / 1000) }
234
- : fields
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
- if (dir !== null) persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
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.11.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": {