@factoidal/core 0.7.1 → 0.11.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 +616 -1
- package/NOTICE +2 -21
- package/README.md +123 -993
- package/api.d.ts +53 -0
- package/api.js +5 -0
- package/api.mjs +56 -0
- package/bin/engine.mjs +38 -19
- package/bin/ext.mjs +16 -20
- package/bin/factoidal.mjs +667 -118
- package/bin/pack.mjs +37 -0
- package/bin/serve-worker.mjs +32 -0
- package/bin/serve.mjs +423 -0
- package/bin/store-worker-host.mjs +1 -1
- package/bin/store.mjs +1 -1
- package/browser.d.ts +114 -507
- package/browser.js +155 -2257
- package/docs/fuseki-replacement.md +108 -0
- package/fn.d.ts +25 -96
- package/fn.js +131 -240
- package/index.d.ts +620 -260
- package/index.js +4 -234
- package/index.mjs +180 -84
- package/l4-assets/l4factoidal.js +47 -7
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +11 -7
- package/lib/api.mjs +2337 -0
- package/lib/browser-engine.mjs +69 -0
- package/lib/dataset.mjs +471 -0
- package/lib/delta-log-hosts.mjs +191 -0
- package/lib/lean-entry.mjs +213 -0
- package/lib/pure.mjs +98 -0
- package/lib/store.mjs +115 -0
- package/lib/version.mjs +5 -0
- package/lws/README.md +137 -0
- package/lws/persist.mjs +78 -0
- package/lws/server.mjs +416 -0
- package/package.json +59 -61
- package/rdfjs.js +5 -536
- package/rdfjs.mjs +517 -0
- package/sample-store.d.ts +8 -16
- package/sample-store.mjs +39 -29
- package/solid/client/README.md +100 -0
- package/solid/client/index.mjs +293 -0
- package/solid/server/README.md +96 -0
- package/solid/server/index.mjs +395 -0
- package/store-host/deno.mjs +8 -0
- package/store-host/index.mjs +12 -0
- package/store-host/node.mjs +8 -0
- package/xmpp/README.md +107 -0
- package/xmpp/index.mjs +229 -0
- package/browser-wasm.js +0 -872
- package/factoidal-npm-entry.js +0 -15477
- package/factoidal-npm-entry.wasm.assets/code-5a7fc68f2ab1323718b8.wasm +0 -0
- package/factoidal-npm-entry.wasm.js +0 -455
- package/factoidal.js +0 -13118
- package/factoidal.wasm.assets/code-bbe6099bfb5b10c4c3ab.wasm +0 -0
- package/factoidal.wasm.js +0 -457
- package/hacl-init.js +0 -92
- package/hacl-wasm/FStar.wasm +0 -0
- package/hacl-wasm/Hacl_Bignum.wasm +0 -0
- package/hacl-wasm/Hacl_Bignum25519_51.wasm +0 -0
- package/hacl-wasm/Hacl_Bignum_Base.wasm +0 -0
- package/hacl-wasm/Hacl_Curve25519_51.wasm +0 -0
- package/hacl-wasm/Hacl_Ed25519.wasm +0 -0
- package/hacl-wasm/Hacl_Ed25519_PrecompTable.wasm +0 -0
- package/hacl-wasm/Hacl_Hash_Base.wasm +0 -0
- package/hacl-wasm/Hacl_Hash_SHA2.wasm +0 -0
- package/hacl-wasm/Hacl_IntTypes_Intrinsics.wasm +0 -0
- package/hacl-wasm/LowStar_Endianness.wasm +0 -0
- package/hacl-wasm/WasmSupport.wasm +0 -0
- package/hacl-wasm/api.js +0 -775
- package/hacl-wasm/api.json +0 -3787
- package/hacl-wasm/layouts.json +0 -1
- package/hacl-wasm/loader.js +0 -568
- package/hacl-wasm/shell.js +0 -12
- package/l4-core.d.ts +0 -159
- package/l4-core.js +0 -188
- package/l4.d.ts +0 -50
- package/l4.js +0 -104
- package/lib/api.js +0 -2300
- package/lib/engine-js.js +0 -165
- package/lib/engine-wasm.js +0 -300
- package/sample-store/CURRENT +0 -1
- package/sample-store/gen-1/manifest.sbm2 +0 -0
- package/sample-store/gen-1/manifest.tsv +0 -14
- package/sample-store/gen-1/predicate-0.ibk3 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-10.ibk3 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-11.ibk3 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-12.ibk3 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-2.ibk3 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-3.ibk3 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-4.ibk3 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +0 -2
- package/sample-store/gen-1/predicate-5.ibk3 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-6.ibk3 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-7.ibk3 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +0 -2
- package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-8.ibk3 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +0 -1
- package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +0 -1
- package/sample-store/gen-1/predicate-9.ibk3 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.merkle +0 -1
- package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +0 -1
- package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +0 -3
- package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +0 -2
- package/select.d.ts +0 -116
- package/select.js +0 -492
- package/version.json +0 -102
- package/wasm.d.ts +0 -130
- package/wasm.js +0 -158
package/lws/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# `@factoidal/core/lws` — a Node host for Linked Web Storage 1.0
|
|
2
|
+
|
|
3
|
+
The W3C Linked Web Storage Protocol 1.0 core
|
|
4
|
+
(<https://w3c.github.io/lws-protocol/lws10-core/>, Linked Web Storage
|
|
5
|
+
Working Group editor's draft) served over Node's `http` module.
|
|
6
|
+
|
|
7
|
+
Tracking issue: <https://github.com/danbri/factoidal/issues/659>.
|
|
8
|
+
Design record:
|
|
9
|
+
[`docs/designissues/2026-09-06-lws-and-solid-protocols.md`](../../../docs/designissues/2026-09-06-lws-and-solid-protocols.md).
|
|
10
|
+
Conformance ledger:
|
|
11
|
+
[`docs/lws-solid-conformance.md`](../../../docs/lws-solid-conformance.md).
|
|
12
|
+
|
|
13
|
+
## What is here and what is not
|
|
14
|
+
|
|
15
|
+
`server.mjs` is a socket, a body reader and a header writer. Every
|
|
16
|
+
protocol decision — status codes, `Link` relations, `Last-Modified`,
|
|
17
|
+
containment, the blank-node refusal in PATCH — is made by
|
|
18
|
+
`formal/lean4/L4Factoidal/LWS/` and reaches this file only as a
|
|
19
|
+
`{status, headers, body}` record. Iron rule 7 of `CLAUDE.md`: no
|
|
20
|
+
protocol logic in a host.
|
|
21
|
+
|
|
22
|
+
The one status code this file writes itself is `500`, and only when the
|
|
23
|
+
host or the engine failed rather than answered.
|
|
24
|
+
|
|
25
|
+
## The wasm contract
|
|
26
|
+
|
|
27
|
+
| op | arguments | answer |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
|
|
30
|
+
| `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
|
|
31
|
+
| `lwsClose` | `[handle]` | `{ ok }` |
|
|
32
|
+
| `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
|
|
33
|
+
| `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
request { method, target, headers: [[name, value]] } + the content as bytes
|
|
37
|
+
response { status, headers: [[name, value]], bodyBytes } + the content as bytes
|
|
38
|
+
(through callBlobIO; https://github.com/danbri/factoidal/issues/889)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`headers` is a list of pairs, not an object: HTTP allows a field name to
|
|
42
|
+
repeat and the order of repeated fields carries meaning. `target` is a
|
|
43
|
+
path within the storage, so Node's `request.url` is passed through
|
|
44
|
+
unchanged and this host composes no IRI.
|
|
45
|
+
|
|
46
|
+
`configJson` members, all optional: `baseIri` (the absolute IRI the
|
|
47
|
+
storage root maps to), `now` (the engine clock in seconds, which advances
|
|
48
|
+
by one second per mutation so a write sequence has increasing
|
|
49
|
+
`Last-Modified` values with no real clock), `owner`, `agent`, `rootAcl`,
|
|
50
|
+
`auth` and `insecureNoAuth` (below). A request
|
|
51
|
+
may carry `now` and `agent` of its own to override the handle's for one
|
|
52
|
+
step; `listen({realClock: true})` is how a server with a real clock feeds
|
|
53
|
+
one in, and a test leaves it off to keep the engine's deterministic
|
|
54
|
+
clock. The full contract is `docs/lws-solid-conformance.md`, section
|
|
55
|
+
"wasm ABI".
|
|
56
|
+
|
|
57
|
+
A WebAssembly module built before these ops landed answers `unknown op`.
|
|
58
|
+
`lwsOpsAvailable(engine)` reports that as `{available: false, reason}`,
|
|
59
|
+
and every test in `tests/lws/` skips with the reason printed rather than
|
|
60
|
+
passing on an engine that cannot answer.
|
|
61
|
+
|
|
62
|
+
## Authorization
|
|
63
|
+
|
|
64
|
+
Authorization follows the draft's "Authorization" section
|
|
65
|
+
(https://github.com/danbri/factoidal/issues/886) and is decided by
|
|
66
|
+
`formal/lean4/L4Factoidal/LWS/Authorization.lean`. It is ON by default.
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
const running = await listen({
|
|
70
|
+
port: 3000,
|
|
71
|
+
owner: 'https://id.example/alice', // root ACL: Read, Write, Control
|
|
72
|
+
auth: { issuers: [{ issuer: 'https://as.example' }] },
|
|
73
|
+
realClock: true
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
* Clients send `Authorization: Bearer <access token>` (RFC 6750). The
|
|
78
|
+
token is a JWT (RFC 9068) signed with ES256, RS256 or EdDSA by a trusted
|
|
79
|
+
issuer; `aud` must contain the storage IRI (`baseIri`, or `auth.audience`).
|
|
80
|
+
* Without a valid token the answer is 401 with
|
|
81
|
+
`WWW-Authenticate: Bearer resource_metadata="…"`; the RFC 9728 metadata
|
|
82
|
+
document is served at `/.well-known/oauth-protected-resource<path>`.
|
|
83
|
+
* The host fetches the issuer's `/.well-known/lws-configuration` and its
|
|
84
|
+
`jwks_uri` when the engine asks (`lwsStep` answers `{ fetch }`), passes
|
|
85
|
+
the bytes to `lwsFetched`, and steps again. `auth.issuers[].jwks` gives
|
|
86
|
+
the keys inline instead.
|
|
87
|
+
* `insecureNoAuth: true` serves every request with no authorization. It is
|
|
88
|
+
for development only, and must be asked for.
|
|
89
|
+
|
|
90
|
+
## Use
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { listen } from '@factoidal/core/lws'
|
|
94
|
+
|
|
95
|
+
const running = await listen({ port: 3000, baseIri: 'https://storage.example/',
|
|
96
|
+
insecureNoAuth: true }) // development only; see Authorization
|
|
97
|
+
console.log(running.origin)
|
|
98
|
+
await running.close()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
From the command:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
factoidal lws-serve /var/lib/lws --port 3000 --issuer https://as.example --owner https://id.example/alice
|
|
105
|
+
factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
|
|
109
|
+
required.
|
|
110
|
+
|
|
111
|
+
`--port 0` takes an ephemeral port and the command prints the one it got;
|
|
112
|
+
`--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
|
|
113
|
+
names the storage directory, but the first slice keeps the resource tree
|
|
114
|
+
in the engine handle, so nothing is read from it or written to it yet
|
|
115
|
+
(design record, section 2).
|
|
116
|
+
|
|
117
|
+
## Exports
|
|
118
|
+
|
|
119
|
+
| name | what it is |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `listen(options)` | start a server, resolve when it is listening |
|
|
122
|
+
| `createLwsServer(options)` | build the server without listening |
|
|
123
|
+
| `openLwsStorage(options)` | open a handle and get `step` / `close` |
|
|
124
|
+
| `createLwsServer(...).setBaseIri(iri)` | set `baseIri` before the first request |
|
|
125
|
+
| `lwsOpsAvailable(engine)` | probe the loaded module for the ops |
|
|
126
|
+
| `LWS_OPS` | the op names |
|
|
127
|
+
| `LwsHostError` | a host failure, with `unknownOp` set when the op is absent |
|
|
128
|
+
|
|
129
|
+
## Tests
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
node tests/lws/server.mjs
|
|
133
|
+
node tests/lws/draft-binding.mjs
|
|
134
|
+
node tests/lws/binary-bodies.mjs
|
|
135
|
+
node tests/lws/authorization.mjs
|
|
136
|
+
deno run --allow-read --allow-net --allow-write --allow-env tests/lws/server.mjs
|
|
137
|
+
```
|
package/lws/persist.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Durable storage for `factoidal lws-serve DIR` and `solid-serve DIR`.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/659
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
5
|
+
// Move bytes between one file and the engine. The engine decides what
|
|
6
|
+
// the bytes are (`L4Factoidal/LWS/Snapshot.lean`): the snapshot document,
|
|
7
|
+
// which paths it may hold (no `..`, no absolute or encoded traversal), and
|
|
8
|
+
// when the storage changed (the `changed` member of the step envelope).
|
|
9
|
+
// This file decides only where the one file lives and how it is written:
|
|
10
|
+
//
|
|
11
|
+
// DIR/lws-state.json the snapshot, replaced atomically
|
|
12
|
+
//
|
|
13
|
+
// No request target ever becomes a file name here, so a request cannot
|
|
14
|
+
// address a file outside DIR. The write is: a temporary file in DIR,
|
|
15
|
+
// fsync, rename over the state file, fsync of DIR. A crash leaves the old
|
|
16
|
+
// snapshot or the new one, never a torn one.
|
|
17
|
+
|
|
18
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
|
|
19
|
+
import { join, resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
export const STATE_FILE = 'lws-state.json'
|
|
22
|
+
|
|
23
|
+
/** The absolute path of the state file of a storage directory. */
|
|
24
|
+
export function stateFileOf (dir) {
|
|
25
|
+
return join(resolve(dir), STATE_FILE)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The parsed snapshot in DIR, or `null` when DIR holds none yet. The
|
|
30
|
+
* engine checks the document; a file that does not parse as JSON is an
|
|
31
|
+
* error of this host, reported as such.
|
|
32
|
+
*/
|
|
33
|
+
export function readSnapshot (dir) {
|
|
34
|
+
const file = stateFileOf(dir)
|
|
35
|
+
if (!existsSync(file)) return null
|
|
36
|
+
const text = readFileSync(file, 'utf8')
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`${file} is not JSON: ${error.message}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a snapshot document to DIR, atomically. */
|
|
45
|
+
export function writeSnapshot (dir, snapshot) {
|
|
46
|
+
const root = resolve(dir)
|
|
47
|
+
mkdirSync(root, { recursive: true })
|
|
48
|
+
const file = stateFileOf(root)
|
|
49
|
+
const temporary = join(root, `.${STATE_FILE}.${process.pid}.tmp`)
|
|
50
|
+
const fd = openSync(temporary, 'w', 0o600)
|
|
51
|
+
try {
|
|
52
|
+
writeSync(fd, JSON.stringify(snapshot))
|
|
53
|
+
fsyncSync(fd)
|
|
54
|
+
} finally {
|
|
55
|
+
closeSync(fd)
|
|
56
|
+
}
|
|
57
|
+
renameSync(temporary, file)
|
|
58
|
+
try {
|
|
59
|
+
const dirFd = openSync(root, 'r')
|
|
60
|
+
try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
|
|
61
|
+
} catch (_error) {
|
|
62
|
+
// Some platforms cannot fsync a directory; the rename stands.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* After one step: when the engine says the storage changed, ask it for
|
|
68
|
+
* the snapshot (`snapshotOp`) and write it.
|
|
69
|
+
*/
|
|
70
|
+
export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
|
|
71
|
+
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
72
|
+
if (envelope.changed !== true) return
|
|
73
|
+
const answer = engine.call(snapshotOp, [handle])
|
|
74
|
+
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
75
|
+
throw new Error(`${snapshotOp} answered with no snapshot`)
|
|
76
|
+
}
|
|
77
|
+
writeSnapshot(dir, answer.snapshot)
|
|
78
|
+
}
|
package/lws/server.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// A Node HTTP host for the Linked Web Storage Protocol 1.0 core.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/659
|
|
3
|
+
// Design record: docs/designissues/2026-09-06-lws-and-solid-protocols.md
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
6
|
+
// Accept a socket, turn one HTTP request into the JSON request record,
|
|
7
|
+
// call the engine's `lwsStep` on a handle, and write the JSON response
|
|
8
|
+
// record back onto the socket. That is the whole contract.
|
|
9
|
+
//
|
|
10
|
+
// It makes NO protocol decision. It does not choose a status code, does
|
|
11
|
+
// not build a `Link` header, does not read or write a resource, does not
|
|
12
|
+
// decide whether a PATCH is refused, and does not know what a container
|
|
13
|
+
// is. Every one of those lives in `formal/lean4/L4Factoidal/LWS/`
|
|
14
|
+
// (iron rule 7 of CLAUDE.md). A reviewer who finds a status code, a
|
|
15
|
+
// media type decision or an RDF term in this file has found a rule
|
|
16
|
+
// violation. The one status code written here is 500, and only for a
|
|
17
|
+
// failure of this host itself.
|
|
18
|
+
//
|
|
19
|
+
// THE WASM CONTRACT
|
|
20
|
+
// Fixed in docs/lws-solid-conformance.md, section "wasm ABI".
|
|
21
|
+
// lwsOpen [configJson] -> { ok, handle, root }
|
|
22
|
+
// lwsStep [handle, requestJson] -> { ok, response }
|
|
23
|
+
// lwsClose [handle] -> { ok }
|
|
24
|
+
// lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
|
|
25
|
+
//
|
|
26
|
+
// `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
|
|
27
|
+
// response: the engine needs an authorization server's metadata or key
|
|
28
|
+
// set before it can decide. This host fetches that URL, passes
|
|
29
|
+
// `{ issuer, url, status, body, now }` to `lwsFetched`, follows any
|
|
30
|
+
// further `fetch` it names, and steps the request again.
|
|
31
|
+
//
|
|
32
|
+
// request { method, target, headers: [[name, value]] } + content bytes
|
|
33
|
+
// response { status, headers: [[name, value]], bodyBytes } + content bytes
|
|
34
|
+
//
|
|
35
|
+
// Bodies are BYTES (https://github.com/danbri/factoidal/issues/889). The
|
|
36
|
+
// step goes through the engine's byte-region entry, `callBlobIO`: the
|
|
37
|
+
// request content is the IN region and the response content is the OUT
|
|
38
|
+
// region, with no encoding and no decoding here. This host never reads a
|
|
39
|
+
// body as text.
|
|
40
|
+
//
|
|
41
|
+
// The config members are `baseIri`, `now`, `owner`, `agent`, `rootAcl`,
|
|
42
|
+
// `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
|
|
43
|
+
// Node's `request.url` through unchanged and never composes an absolute
|
|
44
|
+
// IRI: `baseIri` tells the engine what the root maps to.
|
|
45
|
+
//
|
|
46
|
+
// A request may carry `now` and `agent` beside `method` to override the
|
|
47
|
+
// handle's clock and agent for one step. That is how a host with a real
|
|
48
|
+
// clock and a real token verifier feeds both in; this host sets `now`
|
|
49
|
+
// from its own clock only when the caller asked for it, so a test can
|
|
50
|
+
// keep the engine's deterministic clock.
|
|
51
|
+
//
|
|
52
|
+
// `headers` is a list of pairs rather than an object because HTTP allows
|
|
53
|
+
// a field name to repeat and the order of repeated fields is
|
|
54
|
+
// significant; an object would lose both.
|
|
55
|
+
//
|
|
56
|
+
// The reader of the response envelope accepts the response record either
|
|
57
|
+
// under `response` or spread onto the envelope itself, and the handle
|
|
58
|
+
// either as `handle` or as `id`. This is deliberate tolerance while the
|
|
59
|
+
// Lean side of the contract lands, not two supported shapes: when
|
|
60
|
+
// `docs/lws-solid-conformance.md` fixes one, the other stops being
|
|
61
|
+
// produced and this code keeps working unchanged.
|
|
62
|
+
|
|
63
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
64
|
+
import { readSnapshot, persistIfChanged } from './persist.mjs'
|
|
65
|
+
|
|
66
|
+
/** The ops this host needs from the engine. */
|
|
67
|
+
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched']
|
|
68
|
+
|
|
69
|
+
/** The most documents one request may make this host fetch before the
|
|
70
|
+
* engine has answered it. The engine names at most two per issuer (its
|
|
71
|
+
* metadata, then its key set); this bound only stops a defective module
|
|
72
|
+
* from looping the host. */
|
|
73
|
+
const MAX_FETCHES = 8
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
|
|
77
|
+
* engine decides what the bytes mean. A network failure is reported as
|
|
78
|
+
* status 0, which the engine refuses like any other failed fetch.
|
|
79
|
+
*/
|
|
80
|
+
async function fetchDocument (url) {
|
|
81
|
+
try {
|
|
82
|
+
const answer = await fetch(url, { redirect: 'error' })
|
|
83
|
+
return { status: answer.status, body: await answer.text() }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { status: 0, body: String(error && error.message ? error.message : error) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An error raised by the LWS host, as opposed to one the engine
|
|
90
|
+
* reported. `unknownOp` is set when the loaded WebAssembly module does
|
|
91
|
+
* not carry the operation at all, which is what a test skips on. */
|
|
92
|
+
export class LwsHostError extends Error {
|
|
93
|
+
constructor (message, detail = {}) {
|
|
94
|
+
super(message)
|
|
95
|
+
this.name = 'LwsHostError'
|
|
96
|
+
this.unknownOp = detail.unknownOp === true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isUnknownOp (error) {
|
|
101
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
102
|
+
return text.indexOf('unknown op') >= 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the loaded engine carries the LWS operations.
|
|
107
|
+
*
|
|
108
|
+
* One call is made and its refusal read. A module built before the LWS
|
|
109
|
+
* ops landed answers "unknown op"; anything else -- including an engine
|
|
110
|
+
* complaint about the configuration -- means the op is present.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} engine the handle from `loadEngine()`
|
|
113
|
+
* @returns {Promise<{available: boolean, reason: string}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function lwsOpsAvailable (engine) {
|
|
116
|
+
try {
|
|
117
|
+
const opened = engine.call('lwsOpen', [JSON.stringify({ probe: true })])
|
|
118
|
+
const handle = handleOf(opened)
|
|
119
|
+
if (handle !== null) {
|
|
120
|
+
try { engine.call('lwsClose', [handle]) } catch (_error) { /* probe only */ }
|
|
121
|
+
}
|
|
122
|
+
return { available: true, reason: 'lwsOpen answered' }
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (isUnknownOp(error)) {
|
|
125
|
+
return {
|
|
126
|
+
available: false,
|
|
127
|
+
reason: 'the loaded l4factoidal WebAssembly module does not carry ' +
|
|
128
|
+
'the lws ops (rebuild formal/lean4/Wasm)'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { available: true, reason: 'lwsOpen answered with an engine error' }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleOf (envelope) {
|
|
136
|
+
if (envelope === null || typeof envelope !== 'object') return null
|
|
137
|
+
for (const name of ['handle', 'id']) {
|
|
138
|
+
const value = envelope[name]
|
|
139
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
140
|
+
if (typeof value === 'number') return String(value)
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function responseOf (envelope, bytes) {
|
|
146
|
+
if (envelope === null || typeof envelope !== 'object') {
|
|
147
|
+
throw new LwsHostError('lwsStep answered with no envelope')
|
|
148
|
+
}
|
|
149
|
+
const record = (envelope.response !== null && typeof envelope.response === 'object')
|
|
150
|
+
? envelope.response
|
|
151
|
+
: envelope
|
|
152
|
+
if (typeof record.status !== 'number') {
|
|
153
|
+
throw new LwsHostError('lwsStep answered with no status in its response record')
|
|
154
|
+
}
|
|
155
|
+
const headers = Array.isArray(record.headers) ? record.headers : []
|
|
156
|
+
const body = bytes instanceof Uint8Array ? bytes : new Uint8Array(0)
|
|
157
|
+
return { status: record.status, headers, body }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The request content as bytes: a byte array is passed as it is, and a
|
|
161
|
+
* string (a JavaScript caller's convenience) as its UTF-8 bytes. */
|
|
162
|
+
function contentBytes (request) {
|
|
163
|
+
if (request.body instanceof Uint8Array) return request.body
|
|
164
|
+
if (typeof request.body === 'string') return new TextEncoder().encode(request.body)
|
|
165
|
+
return new Uint8Array(0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Open one LWS storage on the engine and return the handle plus the two
|
|
170
|
+
* calls a host makes against it.
|
|
171
|
+
*
|
|
172
|
+
* The config members are the ABI's own: `baseIri`, `now`, `owner`,
|
|
173
|
+
* `agent`. Nothing else is sent.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} options
|
|
176
|
+
* @param {string} [options.baseIri] the absolute IRI the storage root maps to
|
|
177
|
+
* @param {number} [options.now] the engine clock, seconds since the epoch
|
|
178
|
+
* @param {string} [options.owner] the WebID the storage advertises
|
|
179
|
+
* @param {string} [options.agent] the WebID of the requesting agent
|
|
180
|
+
* @param {boolean} [options.realClock] stamp each request with this host's clock
|
|
181
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
182
|
+
* @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
|
|
183
|
+
*/
|
|
184
|
+
export async function openLwsStorage (options = {}) {
|
|
185
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
186
|
+
const config = {}
|
|
187
|
+
if (typeof options.baseIri === 'string') config.baseIri = options.baseIri
|
|
188
|
+
if (typeof options.now === 'number') config.now = options.now
|
|
189
|
+
if (typeof options.owner === 'string') config.owner = options.owner
|
|
190
|
+
if (typeof options.agent === 'string') config.agent = options.agent
|
|
191
|
+
// Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
|
|
192
|
+
// through unread; `insecureNoAuth` is the explicit development mode.
|
|
193
|
+
// With neither, the engine enforces authorization with no trusted
|
|
194
|
+
// issuer.
|
|
195
|
+
if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
|
|
196
|
+
if (options.insecureNoAuth === true) config.insecureNoAuth = true
|
|
197
|
+
if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
|
|
198
|
+
// `dir` names the storage directory. The snapshot found there, if any,
|
|
199
|
+
// is the storage to continue from; the engine checks it.
|
|
200
|
+
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
201
|
+
if (dir !== null) {
|
|
202
|
+
const snapshot = readSnapshot(dir)
|
|
203
|
+
if (snapshot !== null) config.snapshot = snapshot
|
|
204
|
+
}
|
|
205
|
+
let opened
|
|
206
|
+
try {
|
|
207
|
+
opened = engine.call('lwsOpen', [JSON.stringify(config)])
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (isUnknownOp(error)) {
|
|
210
|
+
throw new LwsHostError(
|
|
211
|
+
'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
|
|
212
|
+
{ unknownOp: true })
|
|
213
|
+
}
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
216
|
+
const handle = handleOf(opened)
|
|
217
|
+
if (handle === null) {
|
|
218
|
+
throw new LwsHostError('lwsOpen answered with no handle')
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
engine,
|
|
222
|
+
handle,
|
|
223
|
+
/**
|
|
224
|
+
* One protocol step.
|
|
225
|
+
* @param {{method: string, target: string,
|
|
226
|
+
* headers: Array<[string, string]>, body: Uint8Array|string}} request
|
|
227
|
+
* @returns {{status: number, headers: Array<[string, string]>, body: Uint8Array}}
|
|
228
|
+
*/
|
|
229
|
+
async step (request) {
|
|
230
|
+
const content = contentBytes(request)
|
|
231
|
+
const { body: _body, ...fields } = request
|
|
232
|
+
const record = options.realClock === true
|
|
233
|
+
? { ...fields, now: Math.floor(Date.now() / 1000) }
|
|
234
|
+
: fields
|
|
235
|
+
const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
|
|
236
|
+
let answered = callStep()
|
|
237
|
+
let envelope = answered.envelope
|
|
238
|
+
// The engine may answer "fetch this document first" (an issuer's
|
|
239
|
+
// metadata or key set). The host fetches exactly what it is told,
|
|
240
|
+
// hands the bytes back, and steps the request again.
|
|
241
|
+
let fetches = 0
|
|
242
|
+
while (envelope !== null && typeof envelope === 'object' &&
|
|
243
|
+
envelope.fetch !== undefined && envelope.fetch !== null) {
|
|
244
|
+
let wanted = envelope.fetch
|
|
245
|
+
while (wanted !== undefined && wanted !== null) {
|
|
246
|
+
fetches += 1
|
|
247
|
+
if (fetches > MAX_FETCHES) {
|
|
248
|
+
throw new LwsHostError('the engine asked for more documents than one request may fetch')
|
|
249
|
+
}
|
|
250
|
+
const got = await fetchDocument(wanted.url)
|
|
251
|
+
const told = engine.call('lwsFetched', [handle, JSON.stringify({
|
|
252
|
+
issuer: wanted.issuer, url: wanted.url, status: got.status, body: got.body,
|
|
253
|
+
...(typeof record.now === 'number' ? { now: record.now } : {})
|
|
254
|
+
})])
|
|
255
|
+
wanted = told !== null && typeof told === 'object' ? told.fetch : null
|
|
256
|
+
}
|
|
257
|
+
answered = callStep()
|
|
258
|
+
envelope = answered.envelope
|
|
259
|
+
}
|
|
260
|
+
const answer = responseOf(envelope, answered.bytes)
|
|
261
|
+
if (dir !== null) persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
|
|
262
|
+
return answer
|
|
263
|
+
},
|
|
264
|
+
close () {
|
|
265
|
+
try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read a Node request body as its bytes. */
|
|
271
|
+
function readBody (request) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const chunks = []
|
|
274
|
+
request.on('data', (chunk) => chunks.push(chunk))
|
|
275
|
+
request.on('error', reject)
|
|
276
|
+
request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Node's flat `rawHeaders` array as the pairs the contract wants. */
|
|
281
|
+
function headerPairs (raw) {
|
|
282
|
+
const pairs = []
|
|
283
|
+
for (let index = 0; index + 1 < raw.length; index += 2) {
|
|
284
|
+
pairs.push([raw[index], raw[index + 1]])
|
|
285
|
+
}
|
|
286
|
+
return pairs
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A Node `http.Server` that answers every request through `lwsStep`.
|
|
291
|
+
*
|
|
292
|
+
* The server is returned unlistened: the caller chooses the port, which
|
|
293
|
+
* is what lets a test take an ephemeral one. The storage handle is
|
|
294
|
+
* opened on demand rather than here, because with an ephemeral port the
|
|
295
|
+
* origin `baseIri` maps the storage root to is not known until the
|
|
296
|
+
* socket is bound.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} options as `openLwsStorage`, plus `onError`
|
|
299
|
+
* @returns {Promise<{server: import('node:http').Server,
|
|
300
|
+
* storage: Function, close: Function,
|
|
301
|
+
* setBaseIri: Function}>}
|
|
302
|
+
*/
|
|
303
|
+
export async function createLwsServer (options = {}) {
|
|
304
|
+
const http = await import('node:http')
|
|
305
|
+
const onError = typeof options.onError === 'function'
|
|
306
|
+
? options.onError
|
|
307
|
+
: (error) => { console.error(`lws-serve: ${error.message}`) }
|
|
308
|
+
|
|
309
|
+
let settings = { ...options }
|
|
310
|
+
let opening = null
|
|
311
|
+
let opened = null
|
|
312
|
+
|
|
313
|
+
/** The storage handle, opened once. */
|
|
314
|
+
function storage () {
|
|
315
|
+
if (opening === null) {
|
|
316
|
+
opening = openLwsStorage(settings).then((value) => { opened = value; return value })
|
|
317
|
+
}
|
|
318
|
+
return opening
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const server = http.createServer(async (request, response) => {
|
|
322
|
+
let record
|
|
323
|
+
try {
|
|
324
|
+
record = {
|
|
325
|
+
method: request.method,
|
|
326
|
+
target: request.url,
|
|
327
|
+
headers: headerPairs(request.rawHeaders),
|
|
328
|
+
body: await readBody(request)
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
onError(error)
|
|
332
|
+
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
333
|
+
response.end('lws-serve: could not read the request body\n')
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
let answer
|
|
337
|
+
try {
|
|
338
|
+
answer = await (await storage()).step(record)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// A failure of this host or of the engine, not a protocol answer.
|
|
341
|
+
// The engine's own words are kept; nothing is invented.
|
|
342
|
+
onError(error)
|
|
343
|
+
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
344
|
+
response.end(`lws-serve: ${error.message}\n`)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
writeHeaders(response, answer.headers)
|
|
348
|
+
response.statusCode = answer.status
|
|
349
|
+
response.end(answer.body)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
server,
|
|
354
|
+
storage,
|
|
355
|
+
/** Set the storage root's absolute IRI, before the first request. */
|
|
356
|
+
setBaseIri (value) {
|
|
357
|
+
if (opening !== null) {
|
|
358
|
+
throw new LwsHostError('the storage is already open; set baseIri first')
|
|
359
|
+
}
|
|
360
|
+
settings = { ...settings, baseIri: value }
|
|
361
|
+
},
|
|
362
|
+
close () {
|
|
363
|
+
return new Promise((resolve) => {
|
|
364
|
+
server.close(() => {
|
|
365
|
+
if (opened !== null) opened.close()
|
|
366
|
+
resolve()
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Write one response record's header pairs, keeping repeats. */
|
|
374
|
+
function writeHeaders (response, pairs) {
|
|
375
|
+
for (const pair of pairs) {
|
|
376
|
+
if (!Array.isArray(pair) || pair.length < 2) continue
|
|
377
|
+
const existing = response.getHeader(pair[0])
|
|
378
|
+
if (existing === undefined) {
|
|
379
|
+
response.setHeader(pair[0], String(pair[1]))
|
|
380
|
+
} else if (Array.isArray(existing)) {
|
|
381
|
+
response.setHeader(pair[0], existing.concat([String(pair[1])]))
|
|
382
|
+
} else {
|
|
383
|
+
response.setHeader(pair[0], [String(existing), String(pair[1])])
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Start a server and resolve once it is listening and the storage is
|
|
390
|
+
* open. The storage is opened here rather than on the first request so
|
|
391
|
+
* that a module without the LWS ops is reported by the command that
|
|
392
|
+
* started the server, not by a later request.
|
|
393
|
+
*
|
|
394
|
+
* @param {object} options as `createLwsServer`, plus `port` and `host`
|
|
395
|
+
* @returns {Promise<{server: object, storage: Function, port: number,
|
|
396
|
+
* origin: string, close: Function}>}
|
|
397
|
+
*/
|
|
398
|
+
export async function listen (options = {}) {
|
|
399
|
+
const port = typeof options.port === 'number' ? options.port : 0
|
|
400
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
401
|
+
const created = await createLwsServer(options)
|
|
402
|
+
await new Promise((resolve, reject) => {
|
|
403
|
+
created.server.once('error', reject)
|
|
404
|
+
created.server.listen(port, host, resolve)
|
|
405
|
+
})
|
|
406
|
+
const bound = created.server.address()
|
|
407
|
+
const origin = `http://${host}:${bound.port}`
|
|
408
|
+
if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
|
|
409
|
+
try {
|
|
410
|
+
await created.storage()
|
|
411
|
+
} catch (error) {
|
|
412
|
+
await new Promise((resolve) => created.server.close(resolve))
|
|
413
|
+
throw error
|
|
414
|
+
}
|
|
415
|
+
return { ...created, port: bound.port, origin }
|
|
416
|
+
}
|