@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/CHANGELOG.md +496 -1
- package/README.md +70 -4
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/bin/ext.mjs +16 -20
- package/bin/factoidal.mjs +308 -69
- package/browser.d.ts +7 -2
- package/browser.js +5 -2
- package/fn.d.ts +1 -1
- package/fn.js +33 -100
- package/index.d.ts +105 -29
- package/index.mjs +5 -2
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +214 -287
- package/lib/dataset.mjs +164 -75
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +36 -22
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +114 -7
- package/lws/persist.mjs +309 -0
- package/lws/server.mjs +364 -17
- package/package.json +2 -1
- package/rdfjs.mjs +151 -226
- package/solid/client/index.mjs +25 -9
- package/solid/server/index.mjs +99 -15
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
|
-
//
|
|
26
|
-
//
|
|
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
|
-
//
|
|
29
|
-
//
|
|
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 =
|
|
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:
|
|
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
|
-
? { ...
|
|
176
|
-
:
|
|
177
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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": {
|