@factoidal/core 0.17.0 → 0.18.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.18.0 (2026-10-27)
6
+
7
+ - XSD constructor functions for datatypes outside the SPARQL 1.1 §17.5 table (`xsd:byte`, `xsd:int`, `xsd:unsignedInt`, `xsd:date`, `xsd:time`, `xsd:gYear` and the other `xsd:g*` types, the duration types, `xsd:token`, …) now cast as XPath F&O 3.1 §19 says. Before, they changed only the datatype and gave ill-typed literals such as `"HaHa"^^xsd:byte`. An invalid value or a cast that F&O does not allow is now an error (the variable is unbound) (https://github.com/danbri/factoidal/issues/1041, https://github.com/danbri/factoidal-core/pull/142).
8
+ - SPARQL: STRSTARTS, STRENDS, CONTAINS, STRBEFORE and STRAFTER compare the base direction of `rdf:dirLangString` arguments (SPARQL 1.2 §17.4.3: same language tag and same base direction). `CONTAINS("abc"@en--ltr, "a"@en)` is now an error. STRBEFORE and STRAFTER keep the base direction of the first argument: `STRBEFORE("abc"@en--ltr, "b")` is `"a"@en--ltr` (https://github.com/danbri/factoidal/issues/1042, https://github.com/danbri/factoidal-core/pull/136).
9
+ - Store open sends the manifest to the engine as bytes, not as hexadecimal inside the JSON arguments, and decodes it once for the plan and the open (new ops `storeManifestInspectBytes`, `storeQueryPlanBytes`, `storeOpenBytes`; the hexadecimal ops stay). On the londat store (1,046 blocks, 753 kB manifest) a scoped `openStoreHandle` takes 198 ms, not 305 ms with 0.17.0 (median of 9) (https://github.com/danbri/factoidal/issues/1039, https://github.com/danbri/factoidal-core/pull/141).
10
+ - Tests: the W3C manifest runner `l4w3c` reads `.n3` expected results when they are Turtle-compatible (else the test is unsupported, with the reason) and runs Jena's `mfx:TestQuery` as a query evaluation test; Jena ARQ manifests: 31 pass (was 13) of 308 (https://github.com/danbri/factoidal-core/pull/138). The Jena ARQ expression suite drops the 62 expected failures these fixes remove. `tools/split-read-differential.sh` runs on macOS and fails when it checks no file (https://github.com/danbri/factoidal-core/pull/133).
11
+ - LWS client (https://github.com/danbri/factoidal/issues/1065): `read` takes `rangeStart`, `rangeEnd`, `ifNoneMatch` and `prefer` and reports `notModified` (304) and `contentRange`; `verifyNotification` takes `trustedStorages` and never asks an untrusted storage for its description (theorem `step_start_verify_trusted`); `search` and `typeIndex` take `onePage` (one page with its `next`, theorem `onPage_onePage_no_fetch`) and `endpoint` (the service endpoint, no discovery); `describe`, `readPage`, `findLinkset`, `putLinkset`, `getSubscription` and `queryFormats` (OPTIONS, `Accept-Query`) are new kinds; `subscribe` takes `expires`; `updateLinkset` takes `linksetUrl`; a session key may be Ed25519 (EdDSA) as well as P-256; the session takes `bearer` (`{token, realm?}`, a token with no realm goes to every request) and `idToken`. New wasm op `lwsClientNewKey` and `createDidKey({ algorithm })` make a P-256 or Ed25519 `did:key` agent from host random bytes (theorems `multikeyJwk?_p256`, `multikeyJwk?_ed25519`). Each `done` document has a `report` (Metadata, Item, Page, Storage, Subscription shapes read in Lean, `Client/Report.lean`) and each `fail` document a `report` with the driver error kind (`LwsClientError.report`). The lws-driver/1 adapter `deploy/lws-client-adapter/adapter.mjs` only translates JSON now; checker result in its README. Needs a wasm with the op `lwsClientNewKey`.
12
+ - WebDAV: `factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]` serves LWS storages to file browsers (macOS Finder, Windows Explorer, GNOME Files, KDE Dolphin) over WebDAV, RFC 4918 classes 1 and 2 (https://github.com/danbri/factoidal/issues/1066). The root collection holds one folder per mount; a mount is one Markdown file `CONFIGDIR/NAME.md` (`- kind: local` with `- path:` an `lws-serve` storage directory, or `- kind: lws` with `- url:` a remote storage and `- auth: none | did-key | bearer`). A local mount is opened by the ops `lws-serve` uses, as the storage's owner; a remote mount is reached through the LWS client, with a self-issued `did:key` credential from a P-256 key file (made when absent). Every decision is in Lean (`L4Factoidal/WebDAV/`); the host `webdav/server.mjs` carries sockets, files and fetches. New wasm ops `webdavOpen`, `webdavLoad`, `webdavStep`, `webdavResume`, `webdavClose`. JS: `listen({ configDir })` and `openWebdav({ configDir })` from `@factoidal/core/webdav`. litmus 0.13: 208 pass, 0 fail (out of 208: basic, copymove, props, locks, http on a local and a remote mount). Not yet: dead properties and locks kept across a restart (https://github.com/danbri/factoidal/issues/1078); checks with the real file browsers (https://github.com/danbri/factoidal/issues/1079). Design record: `docs/designissues/2026-10-27-webdav.md`.
13
+ - JSON-LD-star (RDF 1.2 triple terms, reifiers, annotations; JSON-LD-star draft, json-ld/json-ld-star `1.2-tests`): option `rdfstar` on `jsonldToRdf`, `jsonldFromRdf`, `parse` and `serialize` with `format: 'jsonld'` reads and writes `@triple`, `@reifies` and `@annotation`; without it a triple term is refused when writing JSON-LD. 170 of 171 json-ld-star tests pass (the one fail is a defective fixture, fromRdf tst07); W3C json-ld-api unchanged (791 pass, 2 local-override, of 793) (https://github.com/danbri/factoidal-core/pull/134, https://github.com/danbri/factoidal-core/pull/140).
14
+
5
15
  ## 0.17.0 (2026-10-27)
6
16
 
7
17
  - **Breaking:** `canonicalHash(graph)` now returns the SHA-256 (64 lowercase hex digits) of the RDFC-1.0 canonical N-Quads, as the RDF Dataset Canonicalization specification uses it. Before, it returned the canonical text. Use `canonicalize()` for the text. The engine computes the digest in Lean (new op `datasetCanonicalHash`) (https://github.com/danbri/factoidal/issues/1040, https://github.com/danbri/factoidal-core/pull/127).
package/bin/factoidal.mjs CHANGED
@@ -113,6 +113,8 @@ commands:
113
113
  (RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
114
114
  authz-keys DIR print the public JWK Set of an authorization server
115
115
  xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
116
+ webdav-serve CONFIGDIR serve local and remote LWS storages to file
117
+ browsers over WebDAV (RFC 4918)
116
118
 
117
119
  global options:
118
120
  -h, --help print this text, or a command's own help
@@ -644,6 +646,47 @@ MUC/MIX/GC3 rooms.
644
646
  Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
645
647
  https://www.rfc-editor.org/rfc/rfc6121`,
646
648
 
649
+ 'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
650
+
651
+ usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
652
+
653
+ Starts a WebDAV server (RFC 4918, classes 1 and 2) that macOS Finder,
654
+ Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
655
+ collection holds one folder per mount; a mount is one Markdown file
656
+ CONFIGDIR/NAME.md (README.md is not a mount):
657
+
658
+ # Display name
659
+ - kind: local | lws
660
+ - path: ./alice-storage (local: an lws-serve storage directory)
661
+ - url: https://storage.example/alice/ (lws: the storage root)
662
+ - auth: none | did-key | bearer
663
+ - key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
664
+ - token-file: ./alice.token (bearer: an access token)
665
+ - writable: true | false (default true for local, false for lws)
666
+ - storage: snapshot | files (local: default what the directory holds)
667
+ - base: IRI (local: the storage root's IRI, default http://localhost/)
668
+
669
+ Only the first "# " heading and the first "- key: value" list are read; the
670
+ words in brackets above are notes, not part of a value. A malformed file
671
+ stops the start with its name and line number.
672
+
673
+ A local mount opens the directory with the operations lws-serve uses, as
674
+ the storage's owner (do not serve the same directory with lws-serve at the
675
+ same time). An lws mount reaches the storage through the LWS client.
676
+
677
+ --port N the port to bind (default 8090; 0 takes an ephemeral one)
678
+ --host ADDRESS the address to bind (default 127.0.0.1)
679
+
680
+ Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
681
+ Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
682
+ dav://127.0.0.1:8090/ .
683
+
684
+ The server makes no protocol decision: every method, status, property, lock
685
+ and mapping is decided by formal/lean4/L4Factoidal/WebDAV/. A WebAssembly
686
+ module built before the WebDAV operations landed makes this command exit 3
687
+ and say so. Design: docs/designissues/2026-10-27-webdav.md
688
+ (https://github.com/danbri/factoidal/issues/1066).`,
689
+
647
690
  'solid-client': `factoidal solid-client - run one Solid client operation
648
691
 
649
692
  usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
@@ -788,7 +831,8 @@ const VALUE_OPTIONS = {
788
831
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
789
832
  'cors-origin']),
790
833
  'authz-keys': new Set([]),
791
- 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key'])
834
+ 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
835
+ 'webdav-serve': new Set(['port', 'host'])
792
836
  }
793
837
 
794
838
  // ------------------------------------------------------------- commands
@@ -2027,6 +2071,34 @@ async function commandSolidServe (positional, options) {
2027
2071
  return untilInterrupted(running, quiet)
2028
2072
  }
2029
2073
 
2074
+ // webdav-serve CONFIGDIR (https://github.com/danbri/factoidal/issues/1066):
2075
+ // the host carries sockets, files and fetches; the engine reads the mount
2076
+ // files and decides every answer (L4Factoidal/WebDAV/).
2077
+ async function commandWebdavServe (positional, options) {
2078
+ if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
2079
+ const quiet = options.quiet === true
2080
+ const { listen, WebdavHostError } = await import('../webdav/server.mjs')
2081
+ let running
2082
+ try {
2083
+ running = await listen({ configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) })
2084
+ } catch (error) {
2085
+ if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
2086
+ if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
2087
+ err(`factoidal webdav-serve: ${error.message}`)
2088
+ return EXIT_FAILURE
2089
+ }
2090
+ throw error
2091
+ }
2092
+ if (!quiet) {
2093
+ err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`)
2094
+ for (const m of running.mounts) {
2095
+ err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
2096
+ }
2097
+ }
2098
+ out(running.origin)
2099
+ return untilInterrupted(running, quiet)
2100
+ }
2101
+
2030
2102
  async function commandXmppServe (positional, options) {
2031
2103
  if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
2032
2104
  const quiet = options.quiet === true
@@ -2207,7 +2279,8 @@ const COMMANDS = {
2207
2279
  'lws-client': commandLwsClient,
2208
2280
  'authz-serve': commandAuthzServe,
2209
2281
  'authz-keys': commandAuthzKeys,
2210
- 'xmpp-serve': commandXmppServe
2282
+ 'xmpp-serve': commandXmppServe,
2283
+ 'webdav-serve': commandWebdavServe
2211
2284
  }
2212
2285
 
2213
2286
  // ----------------------------------------------------------------- main
package/bin/store.mjs CHANGED
@@ -123,7 +123,7 @@ export const STACK_REMEDY = {
123
123
  export function openStore (root, generationName = null) {
124
124
  if (typeof generationName !== 'string') {
125
125
  const opened = openCollection(root)
126
- return { ...opened, manifestHex: hexOfBytes(opened.manifest), activated: true }
126
+ return withManifestHex({ ...opened, activated: true })
127
127
  }
128
128
  const generationDir = joinPath(root, generationName)
129
129
  for (const manifestName of MANIFEST_NAMES) {
@@ -133,23 +133,67 @@ export function openStore (root, generationName = null) {
133
133
  } catch (_error) {
134
134
  continue
135
135
  }
136
- return {
136
+ return withManifestHex({
137
137
  root,
138
138
  generation: generationName,
139
139
  generationDir,
140
140
  manifestName,
141
141
  manifest,
142
- manifestHex: hexOfBytes(manifest),
143
142
  activated: false
144
- }
143
+ })
145
144
  }
146
145
  throw new StoreOperationError(
147
146
  `${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`)
148
147
  }
149
148
 
149
+ /**
150
+ * `store.manifestHex`, the hexadecimal the older manifest ops take, computed
151
+ * on first read. The byte ops (`hasManifestBytesOps`) do not read it, so a
152
+ * store opened for them never builds the 1.5 million digits of a large
153
+ * manifest (https://github.com/danbri/factoidal/issues/1039).
154
+ */
155
+ function withManifestHex (store) {
156
+ let hex = null
157
+ return Object.defineProperty(store, 'manifestHex', {
158
+ enumerable: true,
159
+ get () {
160
+ if (hex === null) hex = hexOfBytes(store.manifest)
161
+ return hex
162
+ }
163
+ })
164
+ }
165
+
166
+ /** The byte forms of the manifest ops (formal/lean4/Wasm/Ops/StoreHandles.lean). */
167
+ const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes']
168
+ const manifestBytesSupport = new WeakMap()
169
+
170
+ /**
171
+ * Whether the engine serves the byte forms of the manifest ops. They take
172
+ * the manifest as the byte region, not as hexadecimal inside the JSON
173
+ * arguments, and the engine decodes it once for a plan and the open that
174
+ * follows. An engine built before them has only the hexadecimal ops, and
175
+ * this host then uses those, as before.
176
+ */
177
+ function hasManifestBytesOps (engine) {
178
+ let known = manifestBytesSupport.get(engine)
179
+ if (known === undefined) {
180
+ known = false
181
+ if (typeof engine.callBlobIO === 'function') {
182
+ const reflection = engine.call('ops', [])
183
+ const blobIoOps = Array.isArray(reflection && reflection.blobIoOps) ? reflection.blobIoOps : []
184
+ known = MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))
185
+ }
186
+ manifestBytesSupport.set(engine, known)
187
+ }
188
+ return known
189
+ }
190
+
150
191
  /** `storeManifestInspect` — decode one manifest. */
151
192
  export function inspectManifest (engine, store) {
152
193
  try {
194
+ if (hasManifestBytesOps(engine)) {
195
+ return engine.callBlobIO('storeManifestInspectBytes', [], store.manifest).envelope
196
+ }
153
197
  return engine.call('storeManifestInspect', [store.manifestHex])
154
198
  } catch (error) {
155
199
  throw asStoreError(error)
@@ -159,6 +203,9 @@ export function inspectManifest (engine, store) {
159
203
  /** `storeQueryPlan` — the artifact keys this query needs, and the mode. */
160
204
  export function planQuery (engine, store, sparql) {
161
205
  try {
206
+ if (hasManifestBytesOps(engine)) {
207
+ return engine.callBlobIO('storeQueryPlanBytes', [sparql], store.manifest).envelope
208
+ }
162
209
  return engine.call('storeQueryPlan', [store.manifestHex, sparql])
163
210
  } catch (error) {
164
211
  throw asStoreError(error)
@@ -194,16 +241,20 @@ function capDecision (engine, store, sparql) {
194
241
  }
195
242
  }
196
243
 
197
- /** Read the named artifacts and concatenate them into one region. */
198
- function regionOf (store, keys) {
244
+ /** Read the named artifacts and concatenate them into one region. With a
245
+ * `prefix` (the manifest, for `storeOpenBytes`) the region starts with it,
246
+ * and the windows still count from the first artifact byte. */
247
+ function regionOf (store, keys, prefix = null) {
199
248
  const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
249
+ const start = prefix ? prefix.length : 0
200
250
  let total = 0
201
251
  for (const chunk of chunks) total += chunk.length
202
- const blob = new Uint8Array(total)
252
+ const blob = new Uint8Array(start + total)
253
+ if (prefix) blob.set(prefix, 0)
203
254
  const artifacts = []
204
255
  let offset = 0
205
256
  for (let index = 0; index < chunks.length; index += 1) {
206
- blob.set(chunks[index], offset)
257
+ blob.set(chunks[index], start + offset)
207
258
  artifacts.push({ key: keys[index], offset, len: chunks[index].length })
208
259
  offset += chunks[index].length
209
260
  }
@@ -374,11 +425,16 @@ export function openStoreHandle (engine, store, options = {}) {
374
425
  } else {
375
426
  keys = manifestKeys(engine, store)
376
427
  }
377
- const { blob, artifacts } = regionOf(store, keys)
428
+ const bytesOps = hasManifestBytesOps(engine)
429
+ // The byte op takes the manifest as the first bytes of the region.
430
+ const { blob, artifacts } = regionOf(store, keys, bytesOps ? store.manifest : null)
378
431
  let envelope
379
432
  try {
380
- envelope = engine.callBlobIO('storeOpen',
381
- [store.manifestHex, JSON.stringify(artifacts)], blob).envelope
433
+ envelope = bytesOps
434
+ ? engine.callBlobIO('storeOpenBytes',
435
+ [String(store.manifest.length), JSON.stringify(artifacts)], blob).envelope
436
+ : engine.callBlobIO('storeOpen',
437
+ [store.manifestHex, JSON.stringify(artifacts)], blob).envelope
382
438
  } catch (error) {
383
439
  throw asStoreError(error)
384
440
  }
package/index.d.ts CHANGED
@@ -212,6 +212,9 @@ export interface WriteOptions {
212
212
  useRdfType?: boolean;
213
213
  /** 'jsonld' only. */
214
214
  rdfDirection?: string;
215
+ /** 'jsonld' only: write RDF 1.2 triple terms as JSON-LD-star (`@triple`,
216
+ * `@reifies`, `@annotation`). Without it a triple term is refused. */
217
+ rdfstar?: boolean;
215
218
  }
216
219
 
217
220
  export type WriteFormat =
@@ -1011,6 +1014,9 @@ export interface JsonLdOptions {
1011
1014
  rdfDirection?: string;
1012
1015
  expandContext?: string;
1013
1016
  processingMode?: string;
1017
+ /** JSON-LD-star: read `@triple`, `@reifies`, `@annotation` as RDF 1.2
1018
+ * triple terms and reifiers. */
1019
+ rdfstar?: boolean;
1014
1020
  }
1015
1021
 
1016
1022
  /**
@@ -1027,6 +1033,9 @@ export function jsonldToRdf(
1027
1033
  export interface JsonLdFromRdfOptions {
1028
1034
  useNativeTypes?: boolean;
1029
1035
  useRdfType?: boolean;
1036
+ /** JSON-LD-star: write RDF 1.2 triple terms (`@triple`, `@reifies`,
1037
+ * `@annotation`). Without it a triple term is refused. */
1038
+ rdfstar?: boolean;
1030
1039
  format?: DataFormat;
1031
1040
  }
1032
1041
 
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "ebac38a482d0";
40
+ const WASM_VERSION = "925f357453db";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,17 +2,17 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
4
4
  "stamped": true,
5
- "release": "danbri/factoidal-builds build-fa72ba5bc828-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40439252690",
7
- "gitSha": "fa72ba5bc828c1eb75572c3436e9d739f76fdd57",
8
- "builtAt": "2026-10-27T03:13:16Z",
5
+ "release": "danbri/factoidal-builds build-788ee43aec79-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40457275423",
7
+ "gitSha": "788ee43aec79c4d29592c5e82cc7ab350f0d05dd",
8
+ "builtAt": "2026-10-27T06:36:59Z",
9
9
  "leanToolchain": "leanprover/lean4:v4.33.1",
10
10
  "emscripten": "6.0.8",
11
11
  "abiVersion": "1",
12
12
  "engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
13
13
  "version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
14
- "wasmSha256": "ebac38a482d03d4a6ce1cf4a59164288628fed6957483dd55180743e7a3daf40",
15
- "wasmBytes": 17071472,
14
+ "wasmSha256": "925f357453dbb71d087bef70fd428b054999756d7c967a806403d3d8ee875a57",
15
+ "wasmBytes": 17565361,
16
16
  "claims": {
17
17
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
18
18
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/lib/api.mjs CHANGED
@@ -182,11 +182,10 @@ function utf16Diagnostics(text, diagnostics) {
182
182
  // is never one of a partial generation.
183
183
  // ---------------------------------------------------------------------
184
184
  class Store {
185
- constructor(e, env, gen, manifestHex, fromNQuads, withExtensionRounds) {
185
+ constructor(e, env, gen, fromNQuads, withExtensionRounds) {
186
186
  this._e = e;
187
187
  this._env = env;
188
188
  this._gen = gen;
189
- this._manifestHex = manifestHex;
190
189
  this._closed = false;
191
190
  this._fromNQuads = fromNQuads;
192
191
  this._withExtensionRounds = withExtensionRounds;
@@ -236,7 +235,9 @@ class Store {
236
235
 
237
236
  /** The manifest, decoded by the engine (op storeManifestInspect). */
238
237
  async inspect() {
239
- const r = entryResult(this._e.storeManifestInspect(this._manifestHex), 'Store.inspect');
238
+ const r = entryResult(typeof this._e.storeManifestInspectBytes === 'function'
239
+ ? this._e.storeManifestInspectBytes(this._gen.manifest)
240
+ : this._e.storeManifestInspect(hexOfBytes(this._gen.manifest)), 'Store.inspect');
240
241
  const { ok: _ok, ...rest } = r;
241
242
  return rest;
242
243
  }
@@ -841,7 +842,7 @@ function buildApi(driver) {
841
842
  * @param {Dataset|string|Array} data
842
843
  * @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
843
844
  * literalShorthand?: boolean, useNativeTypes?: boolean,
844
- * useRdfType?: boolean, rdfDirection?: string,
845
+ * useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean,
845
846
  * inputFormat?: string, baseIRI?: string}} [options]
846
847
  * format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
847
848
  * rdfc. inputFormat and baseIRI read text `data`.
@@ -1210,7 +1211,9 @@ function buildApi(driver) {
1210
1211
  * A remote `@context` is not fetched.
1211
1212
  * @param {string} jsonldText
1212
1213
  * @param {{base?: string, rdfDirection?: string, expandContext?: string,
1213
- * processingMode?: string}} [options]
1214
+ * processingMode?: string, rdfstar?: boolean}} [options]
1215
+ * rdfstar: JSON-LD-star (`@triple`, `@reifies`, `@annotation` as RDF 1.2
1216
+ * triple terms and reifiers).
1214
1217
  * @returns {Promise<Dataset>}
1215
1218
  */
1216
1219
  async function jsonldToRdf(jsonldText, options) {
@@ -1223,6 +1226,7 @@ function buildApi(driver) {
1223
1226
  ...(opts.rdfDirection ? { rdfDirection: opts.rdfDirection } : {}),
1224
1227
  ...(opts.expandContext ? { expandContext: opts.expandContext } : {}),
1225
1228
  ...(opts.processingMode ? { processingMode: opts.processingMode } : {}),
1229
+ ...(opts.rdfstar ? { rdfstar: true } : {}),
1226
1230
  });
1227
1231
  return c.wrap(entryResult(c.e.datasetFromJsonLd(jsonldText, optionsJson), 'jsonldToRdf'), {},
1228
1232
  { format: 'jsonld', formatInferred: false });
@@ -1250,7 +1254,8 @@ function buildApi(driver) {
1250
1254
  * The text form is `serialize(data, {format: 'jsonld'})`.
1251
1255
  * @param {Dataset|string|Array} data
1252
1256
  * @param {{useNativeTypes?: boolean, useRdfType?: boolean,
1253
- * rdfDirection?: string, format?: string, baseIRI?: string}} [options]
1257
+ * rdfDirection?: string, rdfstar?: boolean, format?: string,
1258
+ * baseIRI?: string}} [options]
1254
1259
  * @returns {Promise<any>}
1255
1260
  */
1256
1261
  async function jsonldFromRdf(data, options) {
@@ -1907,20 +1912,30 @@ function buildApi(driver) {
1907
1912
  const e = await entry();
1908
1913
  requireEntryFn(e, 'storeOpen', 'openStore (storeOpen)');
1909
1914
  const gen = await openGeneration(reader, opts.generation);
1910
- const manifestHex = hexOfBytes(gen.manifest);
1915
+ // The byte ops when the engine has them (issue 1039): the manifest
1916
+ // crosses as bytes and is decoded once for the plan and the open. An
1917
+ // older engine gets the hexadecimal ops, as before.
1918
+ const bytesOps = typeof e.storeOpenBytes === 'function';
1919
+ const manifestHex = bytesOps ? null : hexOfBytes(gen.manifest);
1911
1920
  let keys;
1912
1921
  if (Array.isArray(opts.keys)) {
1913
1922
  keys = opts.keys.map(String);
1914
1923
  } else if (typeof opts.sparql === 'string') {
1915
- const plan = entryResult(e.storeQueryPlan(manifestHex, opts.sparql), 'openStore(plan)');
1924
+ const plan = entryResult(bytesOps
1925
+ ? e.storeQueryPlanBytes(gen.manifest, opts.sparql)
1926
+ : e.storeQueryPlan(manifestHex, opts.sparql), 'openStore(plan)');
1916
1927
  keys = plan.keys.concat(plan.sidecarKeys || [], plan.blobKeys || []);
1917
1928
  } else {
1918
- keys = manifestKeys(entryResult(e.storeManifestInspect(manifestHex), 'openStore(inspect)'));
1929
+ keys = manifestKeys(entryResult(bytesOps
1930
+ ? e.storeManifestInspectBytes(gen.manifest)
1931
+ : e.storeManifestInspect(manifestHex), 'openStore(inspect)'));
1919
1932
  }
1920
1933
  const { blob, artifacts } = await readRegion(reader, gen.generation, keys);
1921
- const env = entryResult(e.storeOpen(manifestHex, JSON.stringify(artifacts), blob), 'openStore');
1934
+ const env = entryResult(bytesOps
1935
+ ? e.storeOpenBytes(gen.manifest, JSON.stringify(artifacts), blob)
1936
+ : e.storeOpen(manifestHex, JSON.stringify(artifacts), blob), 'openStore');
1922
1937
  const c = await ctx();
1923
- return new Store(e, env, gen, manifestHex, (nq) => c.fromNQuads(nq, {}), withExtensionRounds);
1938
+ return new Store(e, env, gen, (nq) => c.fromNQuads(nq, {}), withExtensionRounds);
1924
1939
  }
1925
1940
 
1926
1941
  /**
package/lib/dataset.mjs CHANGED
@@ -279,7 +279,7 @@ class Dataset {
279
279
  * The dataset as a document.
280
280
  * @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
281
281
  * literalShorthand?: boolean, useNativeTypes?: boolean,
282
- * useRdfType?: boolean, rdfDirection?: string}} [options]
282
+ * useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean}} [options]
283
283
  * format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
284
284
  * rdfc (RDFC-1.0 canonical N-Quads).
285
285
  * @returns {Promise<string>}
@@ -315,6 +315,7 @@ class Dataset {
315
315
  ...(opts.useNativeTypes ? { useNativeTypes: true } : {}),
316
316
  ...(opts.useRdfType ? { useRdfType: true } : {}),
317
317
  ...(opts.rdfDirection ? { rdfDirection: String(opts.rdfDirection) } : {}),
318
+ ...(opts.rdfstar ? { rdfstar: true } : {}),
318
319
  ...(suffix ? { version: String(opts.version) } : {}),
319
320
  })), 'serialize');
320
321
  return r.jsonld;
@@ -125,6 +125,9 @@ function installExtensionBridge(eng, entry) {
125
125
  };
126
126
  }
127
127
 
128
+ /** The byte forms of the manifest ops (Wasm/Ops/StoreHandles.lean). */
129
+ const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes'];
130
+
128
131
  /**
129
132
  * Build the entry object lib/api.mjs drives, from a loaded engine handle.
130
133
  *
@@ -199,9 +202,45 @@ export function entryFromEngine(eng) {
199
202
  }
200
203
  };
201
204
  }
205
+ const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
206
+ // The byte forms of the manifest ops (https://github.com/danbri/factoidal/issues/1039):
207
+ // the manifest crosses as the byte region, not as hexadecimal inside the
208
+ // JSON arguments, and the engine decodes it once for a plan and the open
209
+ // that follows. An engine without them has only the hexadecimal ops; the
210
+ // methods are then absent and lib/api.mjs uses the hexadecimal ops.
211
+ if (typeof eng.callBlobIO === 'function' &&
212
+ MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))) {
213
+ entry.storeManifestInspectBytes = (manifestBytes) => {
214
+ try {
215
+ return JSON.stringify(eng.callBlobIO('storeManifestInspectBytes', [], manifestBytes).envelope);
216
+ } catch (err) {
217
+ return failEnvelope(err);
218
+ }
219
+ };
220
+ entry.storeQueryPlanBytes = (manifestBytes, sparql) => {
221
+ try {
222
+ return JSON.stringify(
223
+ eng.callBlobIO('storeQueryPlanBytes', [String(sparql)], manifestBytes).envelope);
224
+ } catch (err) {
225
+ return failEnvelope(err);
226
+ }
227
+ };
228
+ entry.storeOpenBytes = (manifestBytes, artifactsJson, bytes) => {
229
+ try {
230
+ const region = new Uint8Array(manifestBytes.length + bytes.length);
231
+ region.set(manifestBytes, 0);
232
+ region.set(bytes, manifestBytes.length);
233
+ return JSON.stringify(eng.callBlobIO('storeOpenBytes',
234
+ [String(manifestBytes.length), String(artifactsJson)], region).envelope);
235
+ } catch (err) {
236
+ return failEnvelope(err);
237
+ }
238
+ };
239
+ } else {
240
+ for (const op of MANIFEST_BYTES_OPS) delete entry[op];
241
+ }
202
242
  // datasetQuadsBin answers a byte region (formal/lean4/L4Factoidal/RDF/QuadsBin.lean):
203
243
  // this method answers {envelope, bytes} and throws on a refusal.
204
- const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
205
244
  if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
206
245
  entry.datasetQuadsBin = (handle, optsJson) => {
207
246
  try {
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.17.0';
5
+ export const version = '0.18.0';
package/lws/client.mjs CHANGED
@@ -38,14 +38,15 @@ import { loadEngine } from '../bin/engine.mjs'
38
38
  import { FactoidalError } from '../lib/errors.mjs'
39
39
 
40
40
  /** The ops this host needs from the engine. */
41
- export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
41
+ export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep', 'lwsClientNewKey']
42
42
 
43
43
  /** The operation kinds of the ABI. Listed so a caller can be checked
44
44
  * against the contract; this host attaches no meaning to any of them. */
45
45
  export const LWS_CLIENT_KINDS = [
46
- 'discover', 'read', 'head', 'create', 'update', 'patch', 'delete', 'readLinkset',
47
- 'updateLinkset', 'move', 'list', 'typeIndex', 'search', 'subscribe', 'unsubscribe',
48
- 'requestAccess', 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
46
+ 'discover', 'describe', 'read', 'readPage', 'head', 'create', 'update', 'patch', 'delete',
47
+ 'findLinkset', 'readLinkset', 'updateLinkset', 'putLinkset', 'move', 'list', 'typeIndex',
48
+ 'search', 'queryFormats', 'subscribe', 'getSubscription', 'unsubscribe', 'requestAccess',
49
+ 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
49
50
  ]
50
51
 
51
52
  /** The error an operation ends with. `reason` is the engine's code
@@ -58,6 +59,9 @@ export class LwsClientError extends FactoidalError {
58
59
  this.name = 'LwsClientError'
59
60
  this.reason = reason
60
61
  this.detail = fail
62
+ // The engine's name for the failure in the lws-driver/1 adapter
63
+ // protocol ({ kind, message, ... }), as the engine wrote it.
64
+ this.report = fail && typeof fail.report === 'object' ? fail.report : undefined
61
65
  this.unknownOp = extra.unknownOp === true
62
66
  }
63
67
  }
@@ -138,8 +142,12 @@ export async function lwsClientOpsAvailable (engine) {
138
142
  * @param {string} [options.subjectToken] the authentication credential to
139
143
  * exchange for access tokens (RFC 8693 subject_token)
140
144
  * @param {string} [options.subjectTokenType] its token type URI
141
- * @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 key
142
- * to sign self-issued credentials with, instead of a subject token
145
+ * @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 or
146
+ * Ed25519 key to sign self-issued credentials with, instead of a subject
147
+ * token (`createDidKey` makes one)
148
+ * @param {string} [options.idToken] an OpenID Connect ID Token to exchange
149
+ * @param {object} [options.bearer] `{ token, realm? }`: an access token the
150
+ * caller already has; with a realm it is sent only under it
143
151
  * @param {object} [options.session] a session from an earlier client
144
152
  * @param {number} [options.maxSteps] the loop cap of one operation
145
153
  * @returns {Promise<object>} the client
@@ -157,6 +165,9 @@ export async function createLwsClient (options = {}) {
157
165
  // `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
158
166
  // credentials with (lws10-authn-ssi-cid), passed through unread.
159
167
  if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
168
+ // Read by the engine's session reader (`Machine.sessionOfJson`), unread here.
169
+ if (options.idToken !== undefined) session.idToken = options.idToken
170
+ if (options.bearer !== undefined) session.bearer = options.bearer
160
171
 
161
172
  function call (op, args) {
162
173
  try {
@@ -230,7 +241,10 @@ export async function createLwsClient (options = {}) {
230
241
  /** The session (access tokens and subject token), for a later client. */
231
242
  session: () => ({ ...session }),
232
243
  discover: with_('discover'),
244
+ describe: with_('describe'),
245
+ /** `read(url, { accept, rangeStart, rangeEnd, ifNoneMatch, prefer })` */
233
246
  read: with_('read'),
247
+ readPage: with_('readPage'),
234
248
  head: with_('head'),
235
249
  /** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
236
250
  create: with_('create'),
@@ -239,26 +253,54 @@ export async function createLwsClient (options = {}) {
239
253
  patch: with_('patch'),
240
254
  /** `delete(url, { recursive, precondition, tag })` */
241
255
  delete: with_('delete'),
256
+ findLinkset: with_('findLinkset'),
242
257
  readLinkset: with_('readLinkset'),
258
+ /** `updateLinkset(resourceUrl, { body })`, or with `{ linksetUrl, body }`
259
+ * when the caller has the linkset resource. */
243
260
  updateLinkset: with_('updateLinkset'),
261
+ putLinkset: with_('putLinkset'),
244
262
  /** `move(url, { destination })` */
245
263
  move: with_('move'),
246
264
  list: with_('list'),
247
265
  typeIndex: with_('typeIndex'),
248
- /** `search(anyUrlInTheStorage, { query })` */
266
+ /** `search(anyUrlInTheStorage, { query })`, or `{ endpoint, query }` with
267
+ * the service endpoint, `{ onePage: true }` for one page */
249
268
  search: with_('search'),
269
+ queryFormats: with_('queryFormats'),
270
+ /** `subscribe(url, { topics, inbox, expires })` */
250
271
  subscribe: with_('subscribe'),
272
+ getSubscription: with_('getSubscription'),
251
273
  unsubscribe: with_('unsubscribe'),
252
274
  requestAccess: with_('requestAccess'),
253
275
  grantAccess: with_('grantAccess'),
254
276
  getAccess: with_('getAccess'),
255
277
  revokeAccess: with_('revokeAccess'),
256
- /** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
278
+ /** `verifyNotification(baseUrl, { requestTarget, method, headers, body, trustedStorages })`:
257
279
  * the engine's verdict `{ accept, status, reason? }`. */
258
280
  verifyNotification: with_('verifyNotification')
259
281
  }
260
282
  }
261
283
 
284
+ /**
285
+ * A new `did:key` agent: the engine makes the key pair and the identifier from
286
+ * 64 random bytes of this host's random source. The host reads none of it.
287
+ *
288
+ * @param {object} [options]
289
+ * @param {object} [options.engine] an engine handle to reuse
290
+ * @param {string} [options.algorithm] the engine's name of the algorithm
291
+ * (default `ES256`, a P-256 key; `EdDSA` makes an Ed25519 key)
292
+ * @returns {Promise<{agent: string, kid: string, selfIssued: object}>}
293
+ * `selfIssued` is the `createLwsClient` option of that name
294
+ */
295
+ export async function createDidKey (options = {}) {
296
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
297
+ const algorithm = typeof options.algorithm === 'string' ? options.algorithm : 'ES256'
298
+ const random = new Uint8Array(64)
299
+ globalThis.crypto.getRandomValues(random)
300
+ const answer = engine.call('lwsClientNewKey', [algorithm, base64OfBytes(random)])
301
+ return answer.key
302
+ }
303
+
262
304
  /**
263
305
  * An HTTP receiver for webhook notifications. For each request it asks the
264
306
  * engine (`verifyNotification`) and answers with the status of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -88,6 +88,9 @@
88
88
  "./lws": {
89
89
  "default": "./lws/server.mjs"
90
90
  },
91
+ "./webdav": {
92
+ "default": "./webdav/server.mjs"
93
+ },
91
94
  "./lws/client": {
92
95
  "default": "./lws/client.mjs"
93
96
  },
@@ -123,6 +126,7 @@
123
126
  "lib/",
124
127
  "bin/",
125
128
  "lws/",
129
+ "webdav/",
126
130
  "authz/",
127
131
  "solid/",
128
132
  "store-host/",
@@ -0,0 +1,293 @@
1
+ // A Node HTTP host for the Factoidal WebDAV server.
2
+ // https://github.com/danbri/factoidal/issues/1066
3
+ // Design record: docs/designissues/2026-10-27-webdav.md
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Carry sockets, files and fetches for the engine, and nothing else:
7
+ //
8
+ // * at start, list CONFIGDIR and give the names to `webdavOpen`; read the
9
+ // files and test the paths the engine names (`need`) and give them to
10
+ // `webdavLoad` until it answers `ready`; write the files it names
11
+ // (`write`, a key file it made); open each local storage directory it
12
+ // names with the ops `lws-serve` uses (`openLwsStorage`, the config passed
13
+ // unread);
14
+ // * per request, give the method, target, header fields and content to
15
+ // `webdavStep`, then carry what the engine answers: an `lws` request to
16
+ // the local storage it names (no socket: the storage handle's own step),
17
+ // a `fetch` to the URL it names, until it answers a `response`, which
18
+ // this file writes back.
19
+ //
20
+ // It makes NO protocol decision. Which files are mounts, what a mount file
21
+ // says, every status code, header field, XML body, lock, property and path
22
+ // mapping is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
23
+ // CLAUDE.md). The one status code written here is 500, for a failure of
24
+ // this host itself. Requests are served one at a time (a queue), because
25
+ // the engine keeps one request in flight per handle.
26
+ //
27
+ // THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
28
+ // webdavOpen [configJson] -> { ok, handle, need }
29
+ // webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
30
+ // webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
31
+ // webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
32
+ // webdavClose [handle] -> { ok }
33
+
34
+ import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
35
+ import { resolve as resolvePath } from 'node:path'
36
+ import { loadEngine } from '../bin/engine.mjs'
37
+ import { openLwsStorage } from '../lws/server.mjs'
38
+
39
+ /** The ops this host needs from the engine. */
40
+ export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
41
+
42
+ /** The most engine answers one request may take before this host gives up:
43
+ * a bound against a defective module, not a protocol rule. */
44
+ const MAX_HOPS = 100000
45
+
46
+ /** The most rounds of `need` at start: the engine names two. */
47
+ const MAX_LOADS = 8
48
+
49
+ /** An error of this host, as opposed to one the engine reported. */
50
+ export class WebdavHostError extends Error {
51
+ constructor (message, detail = {}) {
52
+ super(message)
53
+ this.name = 'WebdavHostError'
54
+ this.unknownOp = detail.unknownOp === true
55
+ this.envelope = detail.envelope
56
+ }
57
+ }
58
+
59
+ function isUnknownOp (error) {
60
+ const text = error && error.message ? String(error.message) : String(error)
61
+ return text.indexOf('unknown op') >= 0
62
+ }
63
+
64
+ /** Random bytes as base64. Pure I/O. */
65
+ function randomBase64 (size) {
66
+ const bytes = new Uint8Array(size)
67
+ globalThis.crypto.getRandomValues(bytes)
68
+ return Buffer.from(bytes).toString('base64')
69
+ }
70
+
71
+ function nowSeconds () {
72
+ return Math.floor(Date.now() / 1000)
73
+ }
74
+
75
+ /** One engine call; an `ok: false` envelope is thrown with the engine's words. */
76
+ function callEngine (engine, op, args) {
77
+ let envelope
78
+ try {
79
+ envelope = engine.call(op, args)
80
+ } catch (error) {
81
+ if (isUnknownOp(error)) {
82
+ throw new WebdavHostError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
83
+ }
84
+ throw error
85
+ }
86
+ return envelope
87
+ }
88
+
89
+ function blobCall (engine, op, args, bytes) {
90
+ const got = engine.callBlobIO(op, args, bytes)
91
+ const envelope = got.envelope
92
+ if (envelope === null || typeof envelope !== 'object') throw new WebdavHostError(`${op} answered with no envelope`)
93
+ if (envelope.ok === false) throw new WebdavHostError(`${op}: ${envelope.error}`, { envelope })
94
+ return { envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
95
+ }
96
+
97
+ /** The files and paths the engine named, read and tested. Pure I/O. */
98
+ function gather (configDir, need) {
99
+ const files = (need.read || []).map((name) => {
100
+ try {
101
+ return { name, text: readFileSync(resolvePath(configDir, name), 'utf8') }
102
+ } catch (_error) {
103
+ return { name, missing: true }
104
+ }
105
+ })
106
+ const exists = (need.exists || []).map((path) => ({ path, present: existsSync(resolvePath(configDir, path)) }))
107
+ return { files, exists }
108
+ }
109
+
110
+ /**
111
+ * Open a WebDAV server handle over the mount files of `configDir`.
112
+ *
113
+ * @param {object} options
114
+ * @param {string} options.configDir the directory of mount files
115
+ * @param {object} [options.engine] an engine handle to reuse
116
+ * @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
117
+ */
118
+ export async function openWebdav (options = {}) {
119
+ if (typeof options.configDir !== 'string') throw new WebdavHostError('openWebdav needs configDir')
120
+ const configDir = options.configDir
121
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
122
+ const listing = readdirSync(configDir)
123
+ let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify({ listing, entropy: randomBase64(32) })])
124
+ if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
125
+ const handle = envelope.handle
126
+ let loads = 0
127
+ while (envelope.need !== undefined) {
128
+ loads += 1
129
+ if (loads > MAX_LOADS) throw new WebdavHostError('webdavLoad asked for files more often than a start may')
130
+ envelope = callEngine(engine, 'webdavLoad', [handle, JSON.stringify(gather(configDir, envelope.need))])
131
+ if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
132
+ }
133
+ const ready = envelope.ready
134
+ // A file the engine made (a new key): written once, never over a file.
135
+ for (const w of ready.write || []) {
136
+ writeFileSync(resolvePath(configDir, w.path), w.text, { mode: parseInt(w.mode, 8), flag: 'wx' })
137
+ }
138
+ // Each local storage, opened as lws-serve opens it; the config is the
139
+ // engine's, passed unread.
140
+ const storages = new Map()
141
+ for (const l of ready.local || []) {
142
+ const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true })
143
+ storages.set(l.name, storage)
144
+ }
145
+
146
+ /** Carry one `fetch` the engine named. Pure I/O. */
147
+ async function exchange (request, bytes) {
148
+ const init = { method: request.method, headers: request.headers || [], redirect: 'manual' }
149
+ if (bytes.length > 0) init.body = bytes
150
+ let reply
151
+ try {
152
+ reply = await fetch(request.url, init)
153
+ } catch (error) {
154
+ return { answer: { error: error && error.message ? String(error.message) : String(error) }, bytes: new Uint8Array(0) }
155
+ }
156
+ const headers = []
157
+ reply.headers.forEach((value, name) => { headers.push([name, value]) })
158
+ const body = new Uint8Array(await reply.arrayBuffer())
159
+ return { answer: { status: reply.status, headers, now: nowSeconds(), randomBase64: randomBase64(48) }, bytes: body }
160
+ }
161
+
162
+ let queue = Promise.resolve()
163
+
164
+ /** One request, start to end. */
165
+ async function serve (request) {
166
+ const record = {
167
+ method: request.method,
168
+ target: request.target,
169
+ headers: request.headers,
170
+ now: nowSeconds(),
171
+ randomBase64: randomBase64(16)
172
+ }
173
+ let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
174
+ for (let hop = 0; hop < MAX_HOPS; hop += 1) {
175
+ const env = got.envelope
176
+ if (env.response !== undefined) {
177
+ return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
178
+ }
179
+ let answer
180
+ let bytes
181
+ if (env.lws !== undefined) {
182
+ const storage = storages.get(env.lws.mount)
183
+ if (storage === undefined) throw new WebdavHostError(`the engine named a storage that is not open: ${env.lws.mount}`)
184
+ const req = env.lws.request
185
+ const a = await storage.step({ method: req.method, target: req.target, headers: req.headers || [], body: got.bytes })
186
+ answer = { status: a.status, headers: a.headers }
187
+ bytes = a.body
188
+ } else if (env.fetch !== undefined) {
189
+ const x = await exchange(env.fetch, got.bytes)
190
+ answer = x.answer
191
+ bytes = x.bytes
192
+ } else {
193
+ throw new WebdavHostError('the engine answered neither response, lws nor fetch')
194
+ }
195
+ got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
196
+ }
197
+ throw new WebdavHostError('a request took more engine answers than this host allows')
198
+ }
199
+
200
+ return {
201
+ handle,
202
+ engine,
203
+ mounts: ready.mounts || [],
204
+ /**
205
+ * One request, served after the requests before it.
206
+ * @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
207
+ */
208
+ step (request) {
209
+ const run = queue.then(() => serve(request))
210
+ queue = run.catch(() => {})
211
+ return run
212
+ },
213
+ close () {
214
+ for (const s of storages.values()) s.close()
215
+ try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
216
+ }
217
+ }
218
+ }
219
+
220
+ function readBody (request) {
221
+ return new Promise((resolve, reject) => {
222
+ const chunks = []
223
+ request.on('data', (chunk) => chunks.push(chunk))
224
+ request.on('error', reject)
225
+ request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
226
+ })
227
+ }
228
+
229
+ function headerPairs (raw) {
230
+ const pairs = []
231
+ for (let index = 0; index + 1 < raw.length; index += 2) pairs.push([raw[index], raw[index + 1]])
232
+ return pairs
233
+ }
234
+
235
+ function writeHeaders (response, pairs) {
236
+ for (const pair of pairs) {
237
+ if (!Array.isArray(pair) || pair.length < 2) continue
238
+ const existing = response.getHeader(pair[0])
239
+ if (existing === undefined) response.setHeader(pair[0], String(pair[1]))
240
+ else if (Array.isArray(existing)) response.setHeader(pair[0], existing.concat([String(pair[1])]))
241
+ else response.setHeader(pair[0], [String(existing), String(pair[1])])
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Start the server: open the handle, then listen.
247
+ *
248
+ * @param {object} options `configDir`, `port` (default 8090), `host`
249
+ * (default 127.0.0.1), `engine`, `onError`
250
+ * @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
251
+ */
252
+ export async function listen (options = {}) {
253
+ const http = await import('node:http')
254
+ const dav = await openWebdav(options)
255
+ const onError = typeof options.onError === 'function'
256
+ ? options.onError
257
+ : (error) => { console.error(`webdav-serve: ${error.message}`) }
258
+ const server = http.createServer(async (request, response) => {
259
+ let answer
260
+ try {
261
+ const body = await readBody(request)
262
+ answer = await dav.step({ method: request.method, target: request.url, headers: headerPairs(request.rawHeaders), body })
263
+ } catch (error) {
264
+ onError(error)
265
+ response.writeHead(500, { 'content-type': 'text/plain' })
266
+ response.end(`webdav-serve: ${error.message}\n`)
267
+ return
268
+ }
269
+ writeHeaders(response, answer.headers)
270
+ response.statusCode = answer.status
271
+ response.end(answer.body)
272
+ })
273
+ const port = typeof options.port === 'number' ? options.port : 8090
274
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
275
+ await new Promise((resolve, reject) => {
276
+ server.once('error', reject)
277
+ server.listen(port, host, resolve)
278
+ })
279
+ const bound = server.address()
280
+ const origin = `http://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
281
+ return {
282
+ server,
283
+ port: bound.port,
284
+ origin,
285
+ mounts: dav.mounts,
286
+ close () {
287
+ return new Promise((resolve) => {
288
+ server.close(() => { dav.close(); resolve() })
289
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
290
+ })
291
+ }
292
+ }
293
+ }