@factoidal/core 0.10.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/server.mjs CHANGED
@@ -21,12 +21,48 @@
21
21
  // lwsOpen [configJson] -> { ok, handle, root }
22
22
  // lwsStep [handle, requestJson] -> { ok, response }
23
23
  // lwsClose [handle] -> { ok }
24
+ // lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
25
+ // lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
26
+ // lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
24
27
  //
25
- // request { method, target, headers: [[name, value]], body }
26
- // response { status, headers: [[name, value]], body }
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`.
27
37
  //
28
- // The config members are `baseIri`, `now`, `owner` and `agent`, all
29
- // optional. `target` is a path within the storage, so this host passes
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`.
48
+ //
49
+ // `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
50
+ // response: the engine needs an authorization server's metadata or key
51
+ // set before it can decide. This host fetches that URL, passes
52
+ // `{ issuer, url, status, body, now }` to `lwsFetched`, follows any
53
+ // further `fetch` it names, and steps the request again.
54
+ //
55
+ // request { method, target, headers: [[name, value]] } + content bytes
56
+ // response { status, headers: [[name, value]], bodyBytes } + content bytes
57
+ //
58
+ // Bodies are BYTES (https://github.com/danbri/factoidal/issues/889). The
59
+ // step goes through the engine's byte-region entry, `callBlobIO`: the
60
+ // request content is the IN region and the response content is the OUT
61
+ // region, with no encoding and no decoding here. This host never reads a
62
+ // body as text.
63
+ //
64
+ // The config members are `baseIri`, `now`, `owner`, `agent`, `pageSize`, `rootAcl`,
65
+ // `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
30
66
  // Node's `request.url` through unchanged and never composes an absolute
31
67
  // IRI: `baseIri` tells the engine what the root maps to.
32
68
  //
@@ -48,9 +84,73 @@
48
84
  // produced and this code keeps working unchanged.
49
85
 
50
86
  import { loadEngine } from '../bin/engine.mjs'
87
+ import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged } from './persist.mjs'
51
88
 
52
89
  /** The ops this host needs from the engine. */
53
- export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose']
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
+ }
108
+
109
+ /** The most documents one request may make this host fetch before the
110
+ * engine has answered it. The engine names at most two per issuer (its
111
+ * metadata, then its key set); this bound only stops a defective module
112
+ * from looping the host. */
113
+ const MAX_FETCHES = 8
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
+
141
+ /**
142
+ * Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
143
+ * engine decides what the bytes mean. A network failure is reported as
144
+ * status 0, which the engine refuses like any other failed fetch.
145
+ */
146
+ async function fetchDocument (url) {
147
+ try {
148
+ const answer = await fetch(url, { redirect: 'error' })
149
+ return { status: answer.status, body: await answer.text() }
150
+ } catch (error) {
151
+ return { status: 0, body: String(error && error.message ? error.message : error) }
152
+ }
153
+ }
54
154
 
55
155
  /** An error raised by the LWS host, as opposed to one the engine
56
156
  * reported. `unknownOp` is set when the loaded WebAssembly module does
@@ -108,7 +208,7 @@ function handleOf (envelope) {
108
208
  return null
109
209
  }
110
210
 
111
- function responseOf (envelope) {
211
+ function responseOf (envelope, bytes) {
112
212
  if (envelope === null || typeof envelope !== 'object') {
113
213
  throw new LwsHostError('lwsStep answered with no envelope')
114
214
  }
@@ -119,15 +219,23 @@ function responseOf (envelope) {
119
219
  throw new LwsHostError('lwsStep answered with no status in its response record')
120
220
  }
121
221
  const headers = Array.isArray(record.headers) ? record.headers : []
122
- const body = typeof record.body === 'string' ? record.body : ''
222
+ const body = bytes instanceof Uint8Array ? bytes : new Uint8Array(0)
123
223
  return { status: record.status, headers, body }
124
224
  }
125
225
 
226
+ /** The request content as bytes: a byte array is passed as it is, and a
227
+ * string (a JavaScript caller's convenience) as its UTF-8 bytes. */
228
+ function contentBytes (request) {
229
+ if (request.body instanceof Uint8Array) return request.body
230
+ if (typeof request.body === 'string') return new TextEncoder().encode(request.body)
231
+ return new Uint8Array(0)
232
+ }
233
+
126
234
  /**
127
235
  * Open one LWS storage on the engine and return the handle plus the two
128
236
  * calls a host makes against it.
129
237
  *
130
- * The config members are the ABI's own: `baseIri`, `now`, `owner`,
238
+ * The config members are the ABI's own: `baseIri`, `now`, `owner`, `pageSize`,
131
239
  * `agent`. Nothing else is sent.
132
240
  *
133
241
  * @param {object} options
@@ -135,7 +243,15 @@ function responseOf (envelope) {
135
243
  * @param {number} [options.now] the engine clock, seconds since the epoch
136
244
  * @param {string} [options.owner] the WebID the storage advertises
137
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)
138
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
139
255
  * @param {object} [options.engine] an engine handle to reuse
140
256
  * @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
141
257
  */
@@ -146,6 +262,79 @@ export async function openLwsStorage (options = {}) {
146
262
  if (typeof options.now === 'number') config.now = options.now
147
263
  if (typeof options.owner === 'string') config.owner = options.owner
148
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
268
+ // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
269
+ // through unread; `insecureNoAuth` is the explicit development mode.
270
+ // With neither, the engine enforces authorization with no trusted
271
+ // issuer.
272
+ if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
273
+ if (options.insecureNoAuth === true) config.insecureNoAuth = true
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
332
+ // `dir` names the storage directory. The snapshot found there, if any,
333
+ // is the storage to continue from; the engine checks it.
334
+ if (dir !== null && storage !== 'files') {
335
+ const snapshot = readSnapshot(dir)
336
+ if (snapshot !== null) config.snapshot = snapshot
337
+ }
149
338
  let opened
150
339
  try {
151
340
  opened = engine.call('lwsOpen', [JSON.stringify(config)])
@@ -161,34 +350,183 @@ export async function openLwsStorage (options = {}) {
161
350
  if (handle === null) {
162
351
  throw new LwsHostError('lwsOpen answered with no handle')
163
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
+
164
442
  return {
165
443
  engine,
166
444
  handle,
167
445
  /**
168
446
  * One protocol step.
169
447
  * @param {{method: string, target: string,
170
- * headers: Array<[string, string]>, body: string}} request
171
- * @returns {{status: number, headers: Array<[string, string]>, body: string}}
448
+ * headers: Array<[string, string]>, body: Uint8Array|string}} request
449
+ * @returns {{status: number, headers: Array<[string, string]>, body: Uint8Array}}
172
450
  */
173
- step (request) {
451
+ async step (request) {
452
+ const content = contentBytes(request)
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) }
174
457
  const record = options.realClock === true
175
- ? { ...request, now: Math.floor(Date.now() / 1000) }
176
- : request
177
- return responseOf(engine.call('lwsStep', [handle, JSON.stringify(record)]))
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
463
+ const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
464
+ let answered = callStep()
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
+ }
478
+ // The engine may answer "fetch this document first" (an issuer's
479
+ // metadata or key set). The host fetches exactly what it is told,
480
+ // hands the bytes back, and steps the request again.
481
+ let fetches = 0
482
+ while (envelope !== null && typeof envelope === 'object' &&
483
+ envelope.fetch !== undefined && envelope.fetch !== null) {
484
+ let wanted = envelope.fetch
485
+ while (wanted !== undefined && wanted !== null) {
486
+ fetches += 1
487
+ if (fetches > MAX_FETCHES) {
488
+ throw new LwsHostError('the engine asked for more documents than one request may fetch')
489
+ }
490
+ const got = await fetchDocument(wanted.url)
491
+ const told = engine.call('lwsFetched', [handle, JSON.stringify({
492
+ issuer: wanted.issuer, url: wanted.url, status: got.status, body: got.body,
493
+ ...(typeof record.now === 'number' ? { now: record.now } : {})
494
+ })])
495
+ wanted = told !== null && typeof told === 'object' ? told.fetch : null
496
+ }
497
+ answered = callStep()
498
+ envelope = answered.envelope
499
+ }
500
+ const answer = responseOf(envelope, answered.bytes)
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)
508
+ return answer
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])
178
514
  },
179
515
  close () {
516
+ closed = true
517
+ if (flushTimer !== null) clearTimeout(flushTimer)
180
518
  try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
181
519
  }
182
520
  }
183
521
  }
184
522
 
185
- /** Read a Node request body as one UTF-8 string. */
523
+ /** Read a Node request body as its bytes. */
186
524
  function readBody (request) {
187
525
  return new Promise((resolve, reject) => {
188
526
  const chunks = []
189
527
  request.on('data', (chunk) => chunks.push(chunk))
190
528
  request.on('error', reject)
191
- request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
529
+ request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
192
530
  })
193
531
  }
194
532
 
@@ -250,7 +588,7 @@ export async function createLwsServer (options = {}) {
250
588
  }
251
589
  let answer
252
590
  try {
253
- answer = (await storage()).step(record)
591
+ answer = await (await storage()).step(record)
254
592
  } catch (error) {
255
593
  // A failure of this host or of the engine, not a protocol answer.
256
594
  // The engine's own words are kept; nothing is invented.
@@ -274,6 +612,14 @@ export async function createLwsServer (options = {}) {
274
612
  }
275
613
  settings = { ...settings, baseIri: value }
276
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
+ },
277
623
  close () {
278
624
  return new Promise((resolve) => {
279
625
  server.close(() => {
@@ -321,6 +667,7 @@ export async function listen (options = {}) {
321
667
  const bound = created.server.address()
322
668
  const origin = `http://${host}:${bound.port}`
323
669
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
670
+ created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
324
671
  try {
325
672
  await created.storage()
326
673
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.10.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": {