@factoidal/core 0.19.0 → 0.21.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
@@ -9,6 +9,8 @@
9
9
  // This file decides only where the one file lives and how it is written:
10
10
  //
11
11
  // DIR/lws-state.json the snapshot, replaced atomically
12
+ // DIR/lws.lock the lock of the one writing process (named by
13
+ // the engine, issue 1135; see the end of this file)
12
14
  //
13
15
  // No request target ever becomes a file name here, so a request cannot
14
16
  // address a file outside DIR.
@@ -26,6 +28,7 @@
26
28
 
27
29
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
28
30
  import { dirname, join, resolve, sep } from 'node:path'
31
+ import { hostname } from 'node:os'
29
32
 
30
33
  export const STATE_FILE = 'lws-state.json'
31
34
 
@@ -323,3 +326,169 @@ export function persistFilesIfChanged (engine, handle, envelope, dir) {
323
326
  const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
324
327
  performPlan(dir, answered.envelope.plan, answered.bytes)
325
328
  }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // ONE WRITING PROCESS PER STORAGE DIRECTORY
332
+ // (https://github.com/danbri/factoidal/issues/1135)
333
+ //
334
+ // The engine decides the lock file name, its contents and what a start does
335
+ // (`lwsDirLock`, formal/lean4/L4Factoidal/LWS/DirLock.lean). This host does
336
+ // three things only: the exclusive create of the file it names (flag `wx`,
337
+ // O_EXCL), the liveness probe of a process id it names (`kill(pid, 0)`), and
338
+ // the removal of the file at a clean exit. Each result goes back to the
339
+ // engine as data.
340
+
341
+ /** Random text of this process, drawn once: it tells a lock this process
342
+ * wrote from the lock of an earlier process with the same process id. */
343
+ let processToken = null
344
+ function processTokenText () {
345
+ if (processToken === null) {
346
+ const bytes = new Uint8Array(18)
347
+ globalThis.crypto.getRandomValues(bytes)
348
+ let text = ''
349
+ for (const b of bytes) text += String.fromCharCode(b)
350
+ processToken = btoa(text)
351
+ }
352
+ return processToken
353
+ }
354
+
355
+ /** The lock files this process holds: absolute path -> contents. */
356
+ const heldLocks = new Map()
357
+ let exitHookSet = false
358
+
359
+ /** Remove a lock file this process holds, when it still holds this
360
+ * process's contents. Pure I/O. */
361
+ function removeHeldLock (file) {
362
+ if (!heldLocks.has(file)) return
363
+ const contents = heldLocks.get(file)
364
+ heldLocks.delete(file)
365
+ try {
366
+ if (readFileSync(file, 'utf8') === contents) unlinkSync(file)
367
+ } catch (_error) {
368
+ // Already gone, or not readable: nothing to remove.
369
+ }
370
+ }
371
+
372
+ /** At process exit (normal end, `process.exit`, and after a SIGINT or
373
+ * SIGTERM handler ends the process), remove every lock still held. */
374
+ function ensureExitHook () {
375
+ if (exitHookSet || globalThis.process === undefined || typeof globalThis.process.on !== 'function') return
376
+ exitHookSet = true
377
+ globalThis.process.on('exit', () => {
378
+ for (const file of [...heldLocks.keys()]) removeHeldLock(file)
379
+ })
380
+ }
381
+
382
+ /** Is process `pid` alive on this host? Pure I/O: `kill(pid, 0)`; EPERM
383
+ * means it exists under another user. */
384
+ function processAlive (pid) {
385
+ try {
386
+ globalThis.process.kill(pid, 0)
387
+ return true
388
+ } catch (error) {
389
+ return error !== null && typeof error === 'object' && error.code === 'EPERM'
390
+ }
391
+ }
392
+
393
+ /** Create `file` with O_EXCL and write `contents`; false when it exists. */
394
+ function createExclusive (file, contents) {
395
+ let fd
396
+ try {
397
+ fd = openSync(file, 'wx', 0o600)
398
+ } catch (error) {
399
+ if (error.code === 'EEXIST') return false
400
+ throw error
401
+ }
402
+ try {
403
+ writeSync(fd, contents)
404
+ fsyncSync(fd)
405
+ } finally {
406
+ closeSync(fd)
407
+ }
408
+ return true
409
+ }
410
+
411
+ /** An error that stops a start because another process holds the storage
412
+ * directory. `envelope` carries the engine's message, as for every other
413
+ * refusal of a start. */
414
+ export class StorageLockedError extends Error {
415
+ constructor (message) {
416
+ super(message)
417
+ this.name = 'StorageLockedError'
418
+ this.envelope = { ok: false, error: message }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Take the lock of storage directory `dir` for `command` (`lws-serve`,
424
+ * `solid-serve`, `webdav-serve`). Returns `{ file, warning, release }`:
425
+ * `warning` is the engine's text when it took over a stale lock, else null;
426
+ * `release()` removes the lock. Throws `StorageLockedError` with the
427
+ * engine's message when the engine refuses.
428
+ */
429
+ export function acquireDirLock (engine, dir, command) {
430
+ const root = resolve(dir)
431
+ mkdirSync(root, { recursive: true })
432
+ const base = {
433
+ dir: root,
434
+ pid: globalThis.process.pid,
435
+ command,
436
+ started: new Date().toISOString(),
437
+ host: hostname(),
438
+ token: processTokenText()
439
+ }
440
+ let request = { ...base }
441
+ // Each round is one engine answer; a create that loses to another start
442
+ // reads the file again and asks again.
443
+ for (let round = 0; round < 8; round += 1) {
444
+ const told = engine.call('lwsDirLock', [JSON.stringify(request)])
445
+ if (told === null || typeof told !== 'object' || told.ok === false) {
446
+ throw Object.assign(new Error(`lwsDirLock: ${told && told.error ? told.error : 'no answer'}`), { envelope: told })
447
+ }
448
+ switch (told.action) {
449
+ case 'create':
450
+ case 'replace': {
451
+ const file = insideDir(root, told.file)
452
+ if (told.action === 'replace') {
453
+ try { unlinkSync(file) } catch (error) { if (error.code !== 'ENOENT') throw error }
454
+ }
455
+ if (createExclusive(file, told.contents)) {
456
+ heldLocks.set(file, told.contents)
457
+ ensureExitHook()
458
+ return {
459
+ file,
460
+ warning: told.action === 'replace' ? told.warning : null,
461
+ release: () => removeHeldLock(file)
462
+ }
463
+ }
464
+ let present = null
465
+ try { present = readFileSync(file, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
466
+ request = present === null ? { ...base } : { ...base, present }
467
+ break
468
+ }
469
+ case 'probe':
470
+ request = { ...request, alive: processAlive(Number(told.pid)) }
471
+ break
472
+ case 'refuse':
473
+ throw new StorageLockedError(told.message)
474
+ default:
475
+ throw new Error(`lwsDirLock answered an unknown action ${String(told.action)}`)
476
+ }
477
+ }
478
+ throw new Error('lwsDirLock: the lock file changed on every attempt')
479
+ }
480
+
481
+ /**
482
+ * The start notice about encryption at rest of the storage in `dir`
483
+ * (`lwsAtRestNotice`): `{ sealed, keyId?, notice }`. The key text is passed
484
+ * unread; the engine checks it.
485
+ */
486
+ export function atRestNotice (engine, dir, storageKey) {
487
+ const request = { dir: resolve(dir) }
488
+ if (typeof storageKey === 'string') request.storageKey = storageKey
489
+ const told = engine.call('lwsAtRestNotice', [JSON.stringify(request)])
490
+ if (told === null || typeof told !== 'object' || told.ok === false) {
491
+ throw Object.assign(new Error(`lwsAtRestNotice: ${told && told.error ? told.error : 'no answer'}`), { envelope: told })
492
+ }
493
+ return told
494
+ }
package/lws/server.mjs CHANGED
@@ -84,11 +84,11 @@
84
84
  // produced and this code keeps working unchanged.
85
85
 
86
86
  import { loadEngine } from '../bin/engine.mjs'
87
- import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies } from './persist.mjs'
87
+ import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
88
88
 
89
89
  /** The ops this host needs from the engine. */
90
90
  export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
91
- 'lwsLoad', 'lwsPersist', 'lwsRead']
91
+ 'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
92
92
 
93
93
  /** How long this host waits for the answer to one delivery request before
94
94
  * it reports "no answer" (status 0) to the engine. */
@@ -254,8 +254,11 @@ function contentBytes (request) {
254
254
  * file) or "files" (one file per body and a journal, issue 918); passed unread
255
255
  * @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
256
256
  * origins to answer; default: echo any valid request Origin. Passed unread
257
+ * @param {string} [options.lockCommand] the command named in the lock file of DIR
258
+ * (default "lws-serve"; https://github.com/danbri/factoidal/issues/1135)
257
259
  * @param {object} [options.engine] an engine handle to reuse
258
- * @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
260
+ * @returns {Promise<{step: Function, close: Function, handle: string, engine: object,
261
+ * lockFile: string|null, notices: string[]}>}
259
262
  */
260
263
  export async function openLwsStorage (options = {}) {
261
264
  const engine = options.engine !== undefined ? options.engine : await loadEngine()
@@ -315,8 +318,14 @@ export async function openLwsStorage (options = {}) {
315
318
  if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
316
319
  // CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
317
320
  // a list of origins, passed unread; absent, the engine echoes the request
318
- // Origin. L4Factoidal/LWS/Cors.lean decides every CORS field.
321
+ // Origin under authorization and sends no CORS field with insecureNoAuth
322
+ // (issue 1124). L4Factoidal/LWS/Cors.lean decides every CORS field.
319
323
  if (options.cors !== undefined) config.cors = options.cors
324
+ // The Host and Origin guard of a storage with insecureNoAuth
325
+ // (https://github.com/danbri/factoidal/issues/1124): extra host names and
326
+ // origins, passed unread; L4Factoidal/HTTP/LocalGuard.lean decides.
327
+ if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
328
+ if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
320
329
  // What this host bound: data for the engine's inbox check (issue 915).
321
330
  if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
322
331
  config.entropy = entropyBase64()
@@ -335,36 +344,69 @@ export async function openLwsStorage (options = {}) {
335
344
  const dir = typeof options.dir === 'string' ? options.dir : null
336
345
  const storage = dir !== null ? storageModeText(options.storage) : options.storage
337
346
  if (typeof storage === 'string') config.storage = storage
338
- // `dir` names the storage directory. The snapshot found there, if any,
339
- // is the storage to continue from; the engine checks it.
340
- if (dir !== null && storage !== 'files') {
341
- const snapshot = readSnapshot(dir)
342
- if (snapshot !== null) config.snapshot = snapshot
347
+ // One writing process per storage directory
348
+ // (https://github.com/danbri/factoidal/issues/1135): the lock is taken
349
+ // before anything in DIR is read, and released by `close` and at process
350
+ // exit. The engine decides the file, its contents and every refusal
351
+ // (L4Factoidal/LWS/DirLock.lean); a refusal is thrown with its message.
352
+ let lock = null
353
+ if (dir !== null) {
354
+ try {
355
+ lock = acquireDirLock(engine, dir, typeof options.lockCommand === 'string' ? options.lockCommand : 'lws-serve')
356
+ } catch (error) {
357
+ if (isUnknownOp(error)) {
358
+ throw new LwsHostError(
359
+ 'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
360
+ { unknownOp: true })
361
+ }
362
+ throw error
363
+ }
343
364
  }
344
365
  let opened
366
+ let handle
367
+ let filesMode
368
+ let notices = []
345
369
  try {
346
- opened = engine.call('lwsOpen', [JSON.stringify(config)])
347
- } catch (error) {
348
- if (isUnknownOp(error)) {
349
- throw new LwsHostError(
350
- 'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
351
- { unknownOp: true })
370
+ // `dir` names the storage directory. The snapshot found there, if any,
371
+ // is the storage to continue from; the engine checks it.
372
+ if (dir !== null && storage !== 'files') {
373
+ const snapshot = readSnapshot(dir)
374
+ if (snapshot !== null) config.snapshot = snapshot
352
375
  }
376
+ try {
377
+ opened = engine.call('lwsOpen', [JSON.stringify(config)])
378
+ } catch (error) {
379
+ if (isUnknownOp(error)) {
380
+ throw new LwsHostError(
381
+ 'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
382
+ { unknownOp: true })
383
+ }
384
+ throw error
385
+ }
386
+ handle = handleOf(opened)
387
+ if (handle === null) {
388
+ throw new LwsHostError('lwsOpen answered with no handle')
389
+ }
390
+ // A storage kept in files: read the files the engine names until it is
391
+ // ready, and perform the plan of the start. Without a DIR, there are no
392
+ // files: the engine is told none is present.
393
+ filesMode = opened !== null && typeof opened === 'object' &&
394
+ opened.load !== undefined && opened.load !== null
395
+ if (filesMode) {
396
+ if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
397
+ loadFiles(engine, handle, opened, dir)
398
+ }
399
+ // What a server prints at start, after the experimental code notice:
400
+ // a take-over of a stale lock, and whether the storage on disk is
401
+ // sealed (encryption at rest; the engine words both).
402
+ if (dir !== null) {
403
+ if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
404
+ notices.push(atRestNotice(engine, dir, storageKey).notice)
405
+ }
406
+ } catch (error) {
407
+ if (lock !== null) lock.release()
353
408
  throw error
354
409
  }
355
- const handle = handleOf(opened)
356
- if (handle === null) {
357
- throw new LwsHostError('lwsOpen answered with no handle')
358
- }
359
- // A storage kept in files: read the files the engine names until it is
360
- // ready, and perform the plan of the start. Without a DIR, there are no
361
- // files: the engine is told none is present.
362
- const filesMode = opened !== null && typeof opened === 'object' &&
363
- opened.load !== undefined && opened.load !== null
364
- if (filesMode) {
365
- if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
366
- loadFiles(engine, handle, opened, dir)
367
- }
368
410
  /** After a call whose envelope may say `changed`: write what the engine
369
411
  * gives (the snapshot, or the plan of a storage kept in files). */
370
412
  const persist = (envelope) => {
@@ -448,6 +490,10 @@ export async function openLwsStorage (options = {}) {
448
490
  return {
449
491
  engine,
450
492
  handle,
493
+ /** The lock file of the storage directory, or null with no DIR. */
494
+ lockFile: lock !== null ? lock.file : null,
495
+ /** The start notices, for the server to print on standard error. */
496
+ notices,
451
497
  /**
452
498
  * One protocol step.
453
499
  * @param {{method: string, target: string,
@@ -538,6 +584,7 @@ export async function openLwsStorage (options = {}) {
538
584
  closed = true
539
585
  if (flushTimer !== null) clearTimeout(flushTimer)
540
586
  try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
587
+ if (lock !== null) lock.release()
541
588
  }
542
589
  }
543
590
  }
@@ -593,6 +640,12 @@ export async function createLwsServer (options = {}) {
593
640
  * (L4Factoidal/Authz/Mount.lean). */
594
641
  function storage () {
595
642
  if (opening === null) {
643
+ // What the socket bound, when the caller listened itself and did not
644
+ // say (data for the engine's Host check, issue 1124).
645
+ const bound = server.address()
646
+ if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
647
+ settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
648
+ }
596
649
  opening = (async () => {
597
650
  let storageSettings = settings
598
651
  if (settings.authz !== undefined && settings.authz !== null) {
@@ -720,7 +773,7 @@ export async function listen (options = {}) {
720
773
  const bound = created.server.address()
721
774
  const origin = `http://${host}:${bound.port}`
722
775
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
723
- created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
776
+ created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
724
777
  try {
725
778
  await created.storage()
726
779
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "EXPERIMENTAL UNREVIEWED CODE: do not run public services on it without careful independent expert review. 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",
@@ -70,6 +70,7 @@
70
70
  "./store-host/paths": "./store-host/paths.mjs",
71
71
  "./xmpp": "./xmpp/index.mjs",
72
72
  "./xmpp/server": "./xmpp/server.mjs",
73
+ "./cipher-host": "./lib/cipher-host.mjs",
73
74
  "./store": {
74
75
  "default": "./bin/store.mjs"
75
76
  },
@@ -55,8 +55,9 @@
55
55
  // loader, by the design record's rule that the two conformance classes
56
56
  // stay distinct.
57
57
 
58
+ import { existsSync, readdirSync } from 'node:fs'
58
59
  import { loadEngine } from '../../bin/engine.mjs'
59
- import { readSnapshot, persistIfChanged, storageKeyText } from '../../lws/persist.mjs'
60
+ import { readSnapshot, persistIfChanged, storageKeyText, acquireDirLock, atRestNotice } from '../../lws/persist.mjs'
60
61
 
61
62
  /** The ops this host needs from the engine. */
62
63
  export const SOLID_SERVER_OPS = ['solidOpen', 'solidStep', 'solidClose', 'solidSnapshot',
@@ -187,6 +188,11 @@ export async function openSolidStorage (options = {}) {
187
188
  if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
188
189
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
189
190
  if (typeof options.enforceWac === 'boolean') config.enforceWac = options.enforceWac
191
+ // The Host and Origin guard with insecureNoAuth (issue 1124): what the
192
+ // socket bound and the operator's names and origins, passed unread.
193
+ if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
194
+ if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
195
+ if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
190
196
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
191
197
  // Encryption at rest (issue 921), as the LWS host: the key text from
192
198
  // `storageKey` or FACTOIDAL_LWS_KEY, passed unread; the engine decides.
@@ -195,28 +201,65 @@ export async function openSolidStorage (options = {}) {
195
201
  // `dir` names the storage directory. The snapshot found there, if any,
196
202
  // is the storage to continue from; the engine checks it.
197
203
  const dir = typeof options.dir === 'string' ? options.dir : null
204
+ // One writing process per storage directory
205
+ // (https://github.com/danbri/factoidal/issues/1135), as the LWS host: the
206
+ // engine decides; the lock is taken before DIR is read.
207
+ let lock = null
198
208
  if (dir !== null) {
199
- const snapshot = readSnapshot(dir)
200
- if (snapshot !== null) config.snapshot = snapshot
209
+ try {
210
+ lock = acquireDirLock(engine, dir, 'solid-serve')
211
+ } catch (error) {
212
+ if (isUnknownOp(error)) {
213
+ throw new SolidServerHostError(
214
+ 'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
215
+ { unknownOp: true })
216
+ }
217
+ throw error
218
+ }
201
219
  }
202
220
  let opened
221
+ let handle
222
+ const notices = []
203
223
  try {
204
- opened = engine.call('solidOpen', [JSON.stringify(config)])
205
- } catch (error) {
206
- if (isUnknownOp(error)) {
207
- throw new SolidServerHostError(
208
- 'the loaded l4factoidal WebAssembly module does not carry solidOpen',
209
- { unknownOp: true })
224
+ if (dir !== null) {
225
+ const snapshot = readSnapshot(dir)
226
+ if (snapshot !== null) config.snapshot = snapshot
227
+ // The names in DIR, unread: the engine refuses a storage that LWS
228
+ // keeps there (`solidRefusesLws`, Wasm/Ops/Solid.lean); only lws-serve
229
+ // serves an LWS storage.
230
+ config.dirFiles = existsSync(dir) ? readdirSync(dir) : []
231
+ }
232
+ try {
233
+ opened = engine.call('solidOpen', [JSON.stringify(config)])
234
+ } catch (error) {
235
+ if (isUnknownOp(error)) {
236
+ throw new SolidServerHostError(
237
+ 'the loaded l4factoidal WebAssembly module does not carry solidOpen',
238
+ { unknownOp: true })
239
+ }
240
+ throw error
241
+ }
242
+ handle = handleOf(opened)
243
+ if (handle === null) {
244
+ throw new SolidServerHostError('solidOpen answered with no handle')
210
245
  }
246
+ // The start notices: a take-over of a stale lock, and whether the
247
+ // storage on disk is sealed (encryption at rest).
248
+ if (dir !== null) {
249
+ if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
250
+ notices.push(atRestNotice(engine, dir, storageKey).notice)
251
+ }
252
+ } catch (error) {
253
+ if (lock !== null) lock.release()
211
254
  throw error
212
255
  }
213
- const handle = handleOf(opened)
214
- if (handle === null) {
215
- throw new SolidServerHostError('solidOpen answered with no handle')
216
- }
217
256
  return {
218
257
  engine,
219
258
  handle,
259
+ /** The lock file of the storage directory, or null with no DIR. */
260
+ lockFile: lock !== null ? lock.file : null,
261
+ /** The start notices, for the server to print on standard error. */
262
+ notices,
220
263
  storageIri: typeof opened.storage === 'string' ? opened.storage : null,
221
264
  /**
222
265
  * One protocol step.
@@ -258,6 +301,7 @@ export async function openSolidStorage (options = {}) {
258
301
  },
259
302
  close () {
260
303
  try { engine.call('solidClose', [handle]) } catch (_error) { /* already gone */ }
304
+ if (lock !== null) lock.release()
261
305
  }
262
306
  }
263
307
  }
@@ -302,6 +346,10 @@ export async function createSolidServer (options = {}) {
302
346
 
303
347
  function storage () {
304
348
  if (opening === null) {
349
+ const bound = server.address()
350
+ if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
351
+ settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
352
+ }
305
353
  opening = openSolidStorage(settings).then((value) => { opened = value; return value })
306
354
  }
307
355
  return opening
@@ -345,6 +393,13 @@ export async function createSolidServer (options = {}) {
345
393
  }
346
394
  settings = { ...settings, baseIri: value }
347
395
  },
396
+ /** Pass what the socket bound, before the first request (issue 1124). */
397
+ setListen (value) {
398
+ if (opening !== null) {
399
+ throw new SolidServerHostError('the storage is already open; set listen first')
400
+ }
401
+ settings = { ...settings, listen: value }
402
+ },
348
403
  close () {
349
404
  return new Promise((resolve) => {
350
405
  server.close(() => {
@@ -380,6 +435,16 @@ function writeHeaders (response, pairs) {
380
435
  * @returns {Promise<{server: object, storage: Function, port: number,
381
436
  * origin: string, close: Function}>}
382
437
  */
438
+ /** The addresses of this machine's interfaces. Pure I/O. */
439
+ async function interfaceAddresses () {
440
+ try {
441
+ const os = await import('node:os')
442
+ return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
443
+ } catch (_error) {
444
+ return []
445
+ }
446
+ }
447
+
383
448
  export async function listen (options = {}) {
384
449
  const port = typeof options.port === 'number' ? options.port : 0
385
450
  const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
@@ -391,6 +456,7 @@ export async function listen (options = {}) {
391
456
  const bound = created.server.address()
392
457
  const origin = `http://${host}:${bound.port}`
393
458
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
459
+ created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
394
460
  try {
395
461
  await created.storage()
396
462
  } catch (error) {