nervur 0.20.1 → 0.20.2
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/README.md +130 -8
- package/dist/browser/index.d.ts +35 -0
- package/dist/browser/index.js +188 -0
- package/dist/browser/worker.d.ts +12 -0
- package/dist/browser/worker.js +40 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +7 -0
- package/dist/cli/command.d.ts +1 -1
- package/dist/cli/command.js +59 -16
- package/dist/cli/harbor.d.ts +9 -11
- package/dist/cli/harbor.js +81 -123
- package/dist/contract/index.d.ts +2 -1
- package/dist/contract/index.js +3 -7
- package/dist/edge/index.d.ts +57 -0
- package/dist/edge/index.js +306 -0
- package/dist/edge.d.ts +1 -0
- package/dist/edge.js +8 -0
- package/dist/folder/index.d.ts +4 -0
- package/dist/folder/index.js +57 -14
- package/dist/harbor/carrying.d.ts +17 -0
- package/dist/harbor/carrying.js +38 -0
- package/dist/harbor/catalogue.d.ts +2 -0
- package/dist/harbor/catalogue.js +28 -20
- package/dist/harbor/dock.d.ts +22 -3
- package/dist/harbor/dock.js +69 -13
- package/dist/harbor/harbor.d.ts +14 -4
- package/dist/harbor/harbor.js +144 -15
- package/dist/harbor/index.d.ts +6 -3
- package/dist/harbor/index.js +4 -1
- package/dist/harbor/package.d.ts +2 -0
- package/dist/harbor/package.js +15 -0
- package/dist/harbor/probe.d.ts +18 -0
- package/dist/harbor/probe.js +15 -0
- package/dist/harbor/registry.d.ts +1 -0
- package/dist/harbor/registry.js +11 -0
- package/dist/harbor/relay.d.ts +15 -0
- package/dist/harbor/relay.js +387 -0
- package/dist/harbor/root-line.js +4 -2
- package/dist/harbor/terrain.d.ts +12 -0
- package/dist/harbor/terrain.js +19 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.js +156 -0
- package/dist/http/websocket.d.ts +29 -0
- package/dist/http/websocket.js +133 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/line/answer.d.ts +8 -0
- package/dist/line/answer.js +60 -0
- package/dist/line/frame.d.ts +34 -0
- package/dist/line/frame.js +81 -0
- package/dist/line/ground.d.ts +3 -0
- package/dist/line/ground.js +9 -0
- package/dist/line/index.d.ts +5 -0
- package/dist/line/index.js +9 -0
- package/dist/line/push.d.ts +7 -0
- package/dist/line/push.js +92 -0
- package/dist/line/web.d.ts +30 -0
- package/dist/line/web.js +181 -0
- package/dist/node/index.d.ts +32 -0
- package/dist/node/index.js +164 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +7 -0
- package/dist/pointer/bodies.d.ts +2 -0
- package/dist/pointer/bodies.js +10 -0
- package/dist/pointer/world.d.ts +7 -1
- package/dist/pointer/world.js +6 -0
- package/dist/quo/address.d.ts +19 -0
- package/dist/quo/address.js +84 -0
- package/dist/quo/door.d.ts +1 -1
- package/dist/quo/door.js +4 -2
- package/dist/quo/index.d.ts +1 -0
- package/dist/quo/index.js +1 -0
- package/dist/quo/invitation.d.ts +1 -0
- package/dist/quo/invitation.js +9 -5
- package/dist/tcp/frame.d.ts +0 -4
- package/dist/tcp/frame.js +0 -10
- package/dist/tcp/index.d.ts +8 -2
- package/dist/tcp/index.js +123 -27
- package/dist/ward/index.d.ts +2 -2
- package/dist/ward/index.js +1 -1
- package/dist/ward/stance.d.ts +2 -0
- package/dist/ward/stance.js +3 -1
- package/dist/ward/ward-being.d.ts +2 -1
- package/dist/ward/ward-being.js +1 -1
- package/dist/ward/ward.d.ts +7 -0
- package/dist/ward/ward.js +6 -0
- package/package.json +21 -2
- package/src/browser/index.ts +207 -0
- package/src/browser/worker.ts +66 -0
- package/src/browser.ts +9 -0
- package/src/cli/command.ts +53 -17
- package/src/cli/harbor.ts +82 -116
- package/src/contract/index.ts +11 -8
- package/src/edge/index.ts +359 -0
- package/src/edge.ts +10 -0
- package/src/folder/index.ts +57 -12
- package/src/harbor/carrying.ts +56 -0
- package/src/harbor/catalogue.ts +32 -17
- package/src/harbor/dock.ts +88 -17
- package/src/harbor/harbor.ts +146 -17
- package/src/harbor/index.ts +6 -3
- package/src/harbor/package.ts +16 -0
- package/src/harbor/probe.ts +34 -0
- package/src/harbor/registry.ts +11 -0
- package/src/harbor/relay.ts +399 -0
- package/src/harbor/root-line.ts +4 -1
- package/src/harbor/terrain.ts +28 -2
- package/src/http/index.ts +150 -0
- package/src/http/websocket.ts +124 -0
- package/src/index.ts +35 -2
- package/src/line/answer.ts +59 -0
- package/src/line/frame.ts +88 -0
- package/src/line/ground.ts +19 -0
- package/src/line/index.ts +9 -0
- package/src/line/push.ts +112 -0
- package/src/line/web.ts +227 -0
- package/src/node/index.ts +170 -0
- package/src/node.ts +9 -0
- package/src/pointer/bodies.ts +11 -0
- package/src/pointer/world.ts +18 -2
- package/src/quo/address.ts +87 -0
- package/src/quo/door.ts +4 -2
- package/src/quo/index.ts +1 -0
- package/src/quo/invitation.ts +10 -6
- package/src/stand/stand.ts +60 -21
- package/src/tcp/index.ts +142 -31
- package/src/ward/index.ts +2 -2
- package/src/ward/stance.ts +6 -1
- package/src/ward/ward-being.ts +2 -2
- package/src/ward/ward.ts +16 -0
- package/src/tcp/frame.ts +0 -78
package/README.md
CHANGED
|
@@ -18,6 +18,40 @@ Node 22.18 or later. The main entry imports no platform, so it runs in a
|
|
|
18
18
|
browser, Deno, Bun and workerd as well. `nervur/folder` keeps a harbor on
|
|
19
19
|
a disk and needs Node's file system.
|
|
20
20
|
|
|
21
|
+
## A harbor where you run
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { Harbor } from 'nervur';
|
|
25
|
+
import * as greetings from './greetings.js';
|
|
26
|
+
|
|
27
|
+
const harbor = await Harbor.open({ modules: [greetings] });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With no terrain, the harbor picks its ground's bodies itself. In Node it
|
|
31
|
+
keeps itself in a folder, `where` if you name one, else `$NERVUR_DIR`,
|
|
32
|
+
else `~/.nervur`, speaks TCP and, where you open them, the web and Web
|
|
33
|
+
Push, and serves its root line, so the `nervur` command below asks it
|
|
34
|
+
while it stands. A folder opens in one run at a time. Bundled for a
|
|
35
|
+
browser, in a page or a worker, it keeps itself in the IndexedDB
|
|
36
|
+
database `where` names, else `nervur`, its seed sealed under a key the
|
|
37
|
+
browser never hands out, speaks the web, and opens in one run at a time.
|
|
38
|
+
On Cloudflare Workers it is one Durable Object:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { harborObject } from 'nervur/edge';
|
|
42
|
+
import * as greetings from './greetings.js';
|
|
43
|
+
|
|
44
|
+
export const Harbor = harborObject([greetings], async (harbor) => {
|
|
45
|
+
// the worker's own code is the root: runs once the harbor stands
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Each object keeps its harbor in its storage, its seed sealed under the
|
|
50
|
+
worker's `NERVUR_SECRET`, answers Quo over the web from its requests,
|
|
51
|
+
and asks over TCP and the web. Anywhere else it lives as long as its
|
|
52
|
+
process and speaks the web. Hand in a terrain, as below, and that one
|
|
53
|
+
is used.
|
|
54
|
+
|
|
21
55
|
## A world in one process
|
|
22
56
|
|
|
23
57
|
```js
|
|
@@ -116,13 +150,33 @@ stands a ward under a seed you name, or a drawn one.
|
|
|
116
150
|
- **Harbor, Terrain, Dock, Catalogue, Registry, WardBeing.** The onion.
|
|
117
151
|
- **World, PointerTerrain** and the pointer bodies, `HeldLoader` among
|
|
118
152
|
them. A whole world with this package alone.
|
|
153
|
+
- **addGround, grounds**: the grounds `Harbor.open()` tries, and a way to
|
|
154
|
+
add your own.
|
|
155
|
+
- **NodeTerrain**, from `nervur/node`: the Node ground's terrain, for a
|
|
156
|
+
folder you name in code.
|
|
157
|
+
- **BrowserTerrain, IdbMemory, IdbCustody**, from `nervur/browser`: the
|
|
158
|
+
browser ground's terrain and bodies. `serveWorker(Harbor.open())` at
|
|
159
|
+
the top of a shared or service worker holds one harbor for every tab,
|
|
160
|
+
each tab asks it with `rootOf(worker)`, and a push the service worker
|
|
161
|
+
hears makes every relay client `ring`.
|
|
119
162
|
- **FolderMemory, FolderCustody**, from `nervur/folder`.
|
|
120
163
|
- **TcpCarrier, TcpListener**, from `nervur/tcp`: Quo over TCP. Route a
|
|
121
|
-
ward pk to `host:port` on the carrier, hand the
|
|
122
|
-
and harbors in two processes, or a harbor and
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
164
|
+
ward pk to its `tcp://host:port` addresses on the carrier, hand the
|
|
165
|
+
listener your harbor, and harbors in two processes, or a harbor and
|
|
166
|
+
another kit, speak. `tcpFaculty` is the same carrier as a faculty of a
|
|
167
|
+
harbor.
|
|
168
|
+
- **WebDialer, webFaculty**: Quo over the web, a post to `https://` or a
|
|
169
|
+
held line to `wss://`, in any engine with `fetch` and WebSocket.
|
|
170
|
+
`webServe` and `webGround`, from `nervur/http`, listen for it on Node.
|
|
171
|
+
|
|
172
|
+
- **The relay**, `org.nervur.relay` and `org.nervur.relay-client`, in
|
|
173
|
+
every harbor: a harbor nobody can dial is reached through one that
|
|
174
|
+
can, by relations alone. See "A harbor nobody dials" below.
|
|
175
|
+
- **Push, webPush**: a ring with no content, as Web Push sends it,
|
|
176
|
+
signed with VAPID, on fetch and Web Crypto.
|
|
177
|
+
|
|
178
|
+
Carriers such as MCP, and every screen, are `@nervur-org/*`'s, as
|
|
179
|
+
classes fulfilling these contracts.
|
|
126
180
|
|
|
127
181
|
## The command
|
|
128
182
|
|
|
@@ -134,12 +188,23 @@ folder, Quo over TCP as its carrier. A module is a file that exports
|
|
|
134
188
|
nervur init --dir ./harbor
|
|
135
189
|
nervur module add ./greetings.js --dir ./harbor
|
|
136
190
|
nervur serve --dir ./harbor --port 7000
|
|
191
|
+
nervur reach tcp://harbor.example:7000 --dir ./harbor
|
|
192
|
+
nervur open web org.nervur.web --dir ./harbor
|
|
193
|
+
nervur ask web listen '{"port":8080,"path":"/quo"}' --dir ./harbor
|
|
137
194
|
nervur host alice --dir ./harbor
|
|
138
195
|
nervur boot --ward alice greeter org.example.greeter --dir ./harbor
|
|
139
|
-
nervur route <ward pk> host:port --dir ./harbor
|
|
140
196
|
nervur ask --ward alice greeter hello --dir ./harbor
|
|
141
197
|
```
|
|
142
198
|
|
|
199
|
+
`reach` names where the harbor is reached, and every invitation it gives
|
|
200
|
+
carries those addresses in `at`. With no `reach`, an invitation carries
|
|
201
|
+
where the harbor listens, which serves a harbor callers reach directly;
|
|
202
|
+
behind a router or a proxy, name the public address with `reach`. A
|
|
203
|
+
harbor that takes such an invitation keeps them as that ward's route,
|
|
204
|
+
so nobody routes it by hand. Where an invitation carries no `at`,
|
|
205
|
+
`nervur route <ward pk> tcp://host:port` names the route, and a route
|
|
206
|
+
named this way is trusted first.
|
|
207
|
+
|
|
143
208
|
`init` makes the harbor and prints its pk and an owner invitation. `serve`
|
|
144
209
|
listens for Quo on TCP and for the root's asks on a local socket, open to
|
|
145
210
|
you alone. Every other command is one root ask, sent to the served
|
|
@@ -153,12 +218,14 @@ A harbor elsewhere is piloted with the owner invitation its `init`
|
|
|
153
218
|
printed, from a harbor of your own:
|
|
154
219
|
|
|
155
220
|
```sh
|
|
156
|
-
nervur pilot far '<owner invitation>'
|
|
221
|
+
nervur pilot far '<owner invitation>' --dir ./mine
|
|
157
222
|
nervur host shop --via far --dir ./mine
|
|
158
223
|
```
|
|
159
224
|
|
|
160
225
|
`pilot` boots an `org.nervur.pilot` being in your harbor that holds the
|
|
161
|
-
invitation as an ordinary Quo relation, kept in your harbor's memory
|
|
226
|
+
invitation as an ordinary Quo relation, kept in your harbor's memory, and
|
|
227
|
+
reaches the far harbor where its invitation's `at` says; where it says
|
|
228
|
+
nothing, name the address after the invitation. With
|
|
162
229
|
`--via far`, any command is asked of the far harbor as its owner, sealed
|
|
163
230
|
over TCP, except `own` and `disown`, which stay the far root's.
|
|
164
231
|
`nervur unboot far` lets it go.
|
|
@@ -166,6 +233,61 @@ over TCP, except `own` and `disown`, which stay the far root's.
|
|
|
166
233
|
`rootLine(harbor, request)` is the same request in code, for any other
|
|
167
234
|
front: `{ ward?, method?, args? }` in, one JSON answer out.
|
|
168
235
|
|
|
236
|
+
## A harbor nobody dials
|
|
237
|
+
|
|
238
|
+
A device behind a home router listens nowhere a caller can reach. A
|
|
239
|
+
served harbor that can be reached relays for it. On the relay's harbor:
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
nervur open relay org.nervur.relay --dir ./relay
|
|
243
|
+
nervur ask relay invite '{"id":"house","wards":["<camera ward pk>"]}' --dir ./relay
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
On the device, with the relay's address as its own reach:
|
|
247
|
+
|
|
248
|
+
```sh
|
|
249
|
+
nervur open client org.nervur.relay-client --dir ./house
|
|
250
|
+
nervur ask client hold '{"invitation":<the invitation>,"line":true}' --dir ./house
|
|
251
|
+
nervur reach tcp://relay.example:7000 --dir ./house
|
|
252
|
+
nervur serve --dir ./house
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Every invitation the device gives now sends callers to the relay. The
|
|
256
|
+
relay holds each sealed box, the client collects it by an ordinary ask,
|
|
257
|
+
hands it to the device's door, and returns the sealed reply in its next
|
|
258
|
+
ask. Nothing is opened on the way. `line` keeps collecting, for a
|
|
259
|
+
camera. Without it the client is a doorbell: `ring` collects what waits,
|
|
260
|
+
and `"every": 60000` rings on a schedule, for a garage door.
|
|
261
|
+
|
|
262
|
+
A doorbell can also be rung by a push with nothing in it. The relay's
|
|
263
|
+
harbor opens Web Push and names who it is:
|
|
264
|
+
|
|
265
|
+
```sh
|
|
266
|
+
nervur open push org.nervur.web-push --dir ./relay
|
|
267
|
+
nervur ask push subject '{"subject":"mailto:ops@example.org"}' --dir ./relay
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The device asks its client for the key (`nervur ask client key`),
|
|
271
|
+
subscribes to pushes with it, and hands the subscription's endpoint to
|
|
272
|
+
`nervur ask client bell '{"endpoint":"https://..."}'`. When a box waits
|
|
273
|
+
and no line collects, the relay rings that endpoint once. Whatever hears
|
|
274
|
+
the push on the device asks the client's `ring`.
|
|
275
|
+
|
|
276
|
+
## Moving a harbor
|
|
277
|
+
|
|
278
|
+
A harbor moves to another machine by its bytes, with no harbor open:
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
nervur export --with-seed --dir ./harbor > harbor.json
|
|
282
|
+
nervur import harbor.json --dir ./elsewhere
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`export` reads the folder's sealed places, and carries the seed only
|
|
286
|
+
where you ask for it. `import` writes them into a folder that holds none.
|
|
287
|
+
A package with no seed lands where custody already holds the one it was
|
|
288
|
+
sealed under. In code, `copyPackage(from, to)` carries every place of one
|
|
289
|
+
memory into another.
|
|
290
|
+
|
|
169
291
|
## License
|
|
170
292
|
|
|
171
293
|
Apache-2.0.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Custody, Memory, type Entropy, type Module } from '../contract/index.ts';
|
|
2
|
+
import type { GroundProbe } from '../harbor/index.ts';
|
|
3
|
+
import { PointerTerrain } from '../pointer/index.ts';
|
|
4
|
+
export { ringAll, rootOf, serveWorker, type Reaching } from './worker.ts';
|
|
5
|
+
export declare const BROWSER = "browser";
|
|
6
|
+
export declare const BROWSER_HARBOR = "nervur";
|
|
7
|
+
export declare class IdbMemory extends Memory {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(name?: string);
|
|
10
|
+
get name(): string;
|
|
11
|
+
read(place: string): Promise<Map<string, Uint8Array>>;
|
|
12
|
+
write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void>;
|
|
13
|
+
protected put(store: IDBObjectStore, key: [string, string], bytes: ArrayBuffer | null): void;
|
|
14
|
+
places(): Promise<string[]>;
|
|
15
|
+
forget(place: string): Promise<void>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export declare class IdbCustody extends Custody {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(name?: string, entropy?: Entropy);
|
|
21
|
+
seed(): Promise<Uint8Array>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export type BrowserParts = {
|
|
25
|
+
readonly where?: string;
|
|
26
|
+
readonly modules?: readonly Module[];
|
|
27
|
+
};
|
|
28
|
+
export declare class BrowserTerrain extends PointerTerrain {
|
|
29
|
+
#private;
|
|
30
|
+
readonly where: string;
|
|
31
|
+
constructor(parts?: BrowserParts);
|
|
32
|
+
claim(): Promise<void>;
|
|
33
|
+
release(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare const browserGround: GroundProbe;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur/browser`: the browser ground, in a page or a worker. A harbor
|
|
3
|
+
// keeps its sealed memory and its seed in one IndexedDB database, named
|
|
4
|
+
// by `where`, speaks the web, and holds its database to one run with a
|
|
5
|
+
// Web Lock of the same name.
|
|
6
|
+
//
|
|
7
|
+
// entries one sealed entry per key [place, name]
|
|
8
|
+
// custody the seed, sealed under an AES-GCM key the engine never
|
|
9
|
+
// hands out, itself kept as the engine keeps a CryptoKey
|
|
10
|
+
//
|
|
11
|
+
// A keep is one transaction, so it is whole or nothing by IndexedDB's own
|
|
12
|
+
// rule.
|
|
13
|
+
import { Custody, Memory } from '../contract/index.js';
|
|
14
|
+
import { webFaculty, WEB_OPENING, webPush } from '../line/index.js';
|
|
15
|
+
import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.js';
|
|
16
|
+
export { ringAll, rootOf, serveWorker } from './worker.js';
|
|
17
|
+
export const BROWSER = 'browser';
|
|
18
|
+
// The database a harbor keeps itself in unless named.
|
|
19
|
+
export const BROWSER_HARBOR = 'nervur';
|
|
20
|
+
const ENTRIES = 'entries';
|
|
21
|
+
const CUSTODY = 'custody';
|
|
22
|
+
const SEED = 'seed';
|
|
23
|
+
// Past every lowercase hex name, so [place, END] bounds a place's keys.
|
|
24
|
+
const END = '';
|
|
25
|
+
const done = (request) => new Promise((resolve, reject) => {
|
|
26
|
+
request.onsuccess = () => resolve(request.result);
|
|
27
|
+
request.onerror = () => reject(request.error ?? new Error('IndexedDB refused'));
|
|
28
|
+
});
|
|
29
|
+
// A transaction's end. A request that fails aborts it, and the abort
|
|
30
|
+
// carries that request's error.
|
|
31
|
+
const finished = (tx) => new Promise((resolve, reject) => {
|
|
32
|
+
tx.oncomplete = () => resolve();
|
|
33
|
+
tx.onabort = () => reject(tx.error ?? new Error('the keep was aborted'));
|
|
34
|
+
});
|
|
35
|
+
// One database, opened once per body and made on first open.
|
|
36
|
+
class Database {
|
|
37
|
+
name;
|
|
38
|
+
#open;
|
|
39
|
+
constructor(name) {
|
|
40
|
+
this.name = name;
|
|
41
|
+
}
|
|
42
|
+
get() {
|
|
43
|
+
this.#open ??= new Promise((resolve, reject) => {
|
|
44
|
+
const request = indexedDB.open(this.name, 1);
|
|
45
|
+
request.onupgradeneeded = () => {
|
|
46
|
+
request.result.createObjectStore(ENTRIES);
|
|
47
|
+
request.result.createObjectStore(CUSTODY);
|
|
48
|
+
};
|
|
49
|
+
request.onsuccess = () => resolve(request.result);
|
|
50
|
+
request.onerror = () => reject(request.error ?? new Error(`${this.name} did not open`));
|
|
51
|
+
});
|
|
52
|
+
return this.#open;
|
|
53
|
+
}
|
|
54
|
+
async close() {
|
|
55
|
+
const open = this.#open;
|
|
56
|
+
this.#open = undefined;
|
|
57
|
+
if (open)
|
|
58
|
+
(await open).close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class IdbMemory extends Memory {
|
|
62
|
+
#db;
|
|
63
|
+
constructor(name = BROWSER_HARBOR) {
|
|
64
|
+
super();
|
|
65
|
+
this.#db = new Database(name);
|
|
66
|
+
}
|
|
67
|
+
// The database it keeps in.
|
|
68
|
+
get name() {
|
|
69
|
+
return this.#db.name;
|
|
70
|
+
}
|
|
71
|
+
async read(place) {
|
|
72
|
+
const store = (await this.#db.get()).transaction(ENTRIES).objectStore(ENTRIES);
|
|
73
|
+
const range = IDBKeyRange.bound([place, ''], [place, END]);
|
|
74
|
+
const [keys, values] = await Promise.all([done(store.getAllKeys(range)), done(store.getAll(range))]);
|
|
75
|
+
return new Map(keys.map((key, i) => [key[1], new Uint8Array(values[i])]));
|
|
76
|
+
}
|
|
77
|
+
async write(place, entries) {
|
|
78
|
+
const tx = (await this.#db.get()).transaction(ENTRIES, 'readwrite');
|
|
79
|
+
const kept = finished(tx);
|
|
80
|
+
const store = tx.objectStore(ENTRIES);
|
|
81
|
+
try {
|
|
82
|
+
for (const [name, bytes] of entries)
|
|
83
|
+
this.put(store, [place, name], bytes === null ? null : bytes.slice().buffer);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
tx.abort();
|
|
87
|
+
await kept.catch(() => undefined);
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
await kept;
|
|
91
|
+
}
|
|
92
|
+
// One entry of a keep: its bytes, or null where it goes.
|
|
93
|
+
put(store, key, bytes) {
|
|
94
|
+
if (bytes === null)
|
|
95
|
+
store.delete(key);
|
|
96
|
+
else
|
|
97
|
+
store.put(bytes, key);
|
|
98
|
+
}
|
|
99
|
+
async places() {
|
|
100
|
+
const keys = await done((await this.#db.get()).transaction(ENTRIES).objectStore(ENTRIES).getAllKeys());
|
|
101
|
+
return [...new Set(keys.map((key) => key[0]))];
|
|
102
|
+
}
|
|
103
|
+
async forget(place) {
|
|
104
|
+
const tx = (await this.#db.get()).transaction(ENTRIES, 'readwrite');
|
|
105
|
+
tx.objectStore(ENTRIES).delete(IDBKeyRange.bound([place, ''], [place, END]));
|
|
106
|
+
await finished(tx);
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
return this.#db.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export class IdbCustody extends Custody {
|
|
113
|
+
#db;
|
|
114
|
+
#entropy;
|
|
115
|
+
constructor(name = BROWSER_HARBOR, entropy = new CryptoEntropy()) {
|
|
116
|
+
super();
|
|
117
|
+
this.#db = new Database(name);
|
|
118
|
+
this.#entropy = entropy;
|
|
119
|
+
}
|
|
120
|
+
// The seed the database holds, or a drawn one sealed there the first
|
|
121
|
+
// time. Two first asks at once keep the one added first.
|
|
122
|
+
async seed() {
|
|
123
|
+
const db = await this.#db.get();
|
|
124
|
+
const kept = (await done(db.transaction(CUSTODY).objectStore(CUSTODY).get(SEED)));
|
|
125
|
+
if (kept)
|
|
126
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: kept.iv }, kept.key, kept.sealed));
|
|
127
|
+
const seed = this.#entropy.draw(32);
|
|
128
|
+
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
129
|
+
const iv = new Uint8Array(this.#entropy.draw(12));
|
|
130
|
+
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new Uint8Array(seed));
|
|
131
|
+
const tx = db.transaction(CUSTODY, 'readwrite');
|
|
132
|
+
tx.objectStore(CUSTODY).add({ key, iv, sealed }, SEED);
|
|
133
|
+
try {
|
|
134
|
+
await finished(tx);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
if (e.name === 'ConstraintError')
|
|
138
|
+
return this.seed();
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
return seed;
|
|
142
|
+
}
|
|
143
|
+
close() {
|
|
144
|
+
return this.#db.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// A harbor of the browser: its database, the web opened at genesis, Web
|
|
148
|
+
// Push beside it, and one run to a database, held by a Web Lock where the
|
|
149
|
+
// engine has them.
|
|
150
|
+
export class BrowserTerrain extends PointerTerrain {
|
|
151
|
+
where;
|
|
152
|
+
#release;
|
|
153
|
+
constructor(parts = {}) {
|
|
154
|
+
const where = parts.where ?? BROWSER_HARBOR;
|
|
155
|
+
const entropy = new CryptoEntropy();
|
|
156
|
+
super({ entropy, loader: new HeldLoader(parts.modules ?? []), custody: new IdbCustody(where, entropy), memory: new IdbMemory(where), faculties: [webFaculty(), webPush], opens: [WEB_OPENING] });
|
|
157
|
+
this.where = where;
|
|
158
|
+
}
|
|
159
|
+
async claim() {
|
|
160
|
+
const locks = globalThis.navigator?.locks;
|
|
161
|
+
if (!locks)
|
|
162
|
+
return;
|
|
163
|
+
const held = await new Promise((granted) => {
|
|
164
|
+
void locks.request(`nervur:${this.where}`, { ifAvailable: true }, (lock) => {
|
|
165
|
+
if (lock === null) {
|
|
166
|
+
granted(false);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
granted(true);
|
|
170
|
+
return new Promise((release) => (this.#release = release));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
if (!held)
|
|
174
|
+
throw new Error(`a harbor is already open on ${this.where}`);
|
|
175
|
+
}
|
|
176
|
+
async release() {
|
|
177
|
+
this.#release?.();
|
|
178
|
+
this.#release = undefined;
|
|
179
|
+
await this.memory.close();
|
|
180
|
+
await this.custody.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// A page or a worker with IndexedDB and Web Crypto.
|
|
184
|
+
export const browserGround = {
|
|
185
|
+
name: BROWSER,
|
|
186
|
+
fits: () => typeof indexedDB !== 'undefined' && typeof crypto?.subtle !== 'undefined',
|
|
187
|
+
terrain: ({ modules, where }) => new BrowserTerrain({ ...(where ? { where } : {}), ...(modules ? { modules } : {}) }),
|
|
188
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JsonObject } from '../being/index.ts';
|
|
2
|
+
import { type Harbor } from '../harbor/index.ts';
|
|
3
|
+
type Scope = {
|
|
4
|
+
addEventListener(type: string, listener: (event: never) => void): void;
|
|
5
|
+
};
|
|
6
|
+
export declare const ringAll: (harbor: Harbor) => Promise<JsonObject[]>;
|
|
7
|
+
export declare const serveWorker: (harbor: Promise<Harbor>, scope?: Scope) => void;
|
|
8
|
+
export type Reaching = {
|
|
9
|
+
postMessage(message: unknown, transfer: Transferable[]): void;
|
|
10
|
+
};
|
|
11
|
+
export declare const rootOf: (worker: Reaching) => (request: JsonObject) => Promise<JsonObject>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { RELAY_CLIENT, rootLine } from '../harbor/index.js';
|
|
2
|
+
const answer = async (harbor, data, reply) => {
|
|
3
|
+
if (!reply)
|
|
4
|
+
return;
|
|
5
|
+
let out;
|
|
6
|
+
try {
|
|
7
|
+
out = await rootLine(await harbor, data?.request);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
out = { error: 'the harbor threw' };
|
|
11
|
+
}
|
|
12
|
+
reply.postMessage(out);
|
|
13
|
+
};
|
|
14
|
+
// Every relay client of the harbor asked to collect what waits.
|
|
15
|
+
export const ringAll = async (harbor) => {
|
|
16
|
+
const keys = Object.entries(harbor.box.partition.beings)
|
|
17
|
+
.filter(([, row]) => row.class === RELAY_CLIENT)
|
|
18
|
+
.map(([key]) => key);
|
|
19
|
+
return Promise.all(keys.map(async (being) => (await harbor.root('ask', { being, method: 'ring' }))));
|
|
20
|
+
};
|
|
21
|
+
// Called as the worker's script starts, so no tab's first message is
|
|
22
|
+
// lost while the harbor opens.
|
|
23
|
+
export const serveWorker = (harbor, scope = globalThis) => {
|
|
24
|
+
scope.addEventListener('connect', (event) => {
|
|
25
|
+
const port = event.ports[0];
|
|
26
|
+
port.onmessage = (message) => void answer(harbor, message.data, message.ports[0]);
|
|
27
|
+
port.start();
|
|
28
|
+
});
|
|
29
|
+
scope.addEventListener('message', (event) => void answer(harbor, event.data, event.ports[0]));
|
|
30
|
+
scope.addEventListener('push', (event) => event.waitUntil(harbor.then(ringAll)));
|
|
31
|
+
};
|
|
32
|
+
// A tab's root line to the worker's harbor.
|
|
33
|
+
export const rootOf = (worker) => (request) => new Promise((resolve) => {
|
|
34
|
+
const channel = new MessageChannel();
|
|
35
|
+
channel.port1.onmessage = (message) => {
|
|
36
|
+
channel.port1.close();
|
|
37
|
+
resolve(message.data);
|
|
38
|
+
};
|
|
39
|
+
worker.postMessage({ request }, [channel.port2]);
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './index.ts';
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur` as a browser bundle reads it: the main entry, with the browser
|
|
3
|
+
// ground added, so `Harbor.open()` keeps a harbor in IndexedDB.
|
|
4
|
+
import { browserGround } from './browser/index.js';
|
|
5
|
+
import { addGround } from './harbor/index.js';
|
|
6
|
+
export * from './index.js';
|
|
7
|
+
addGround(browserGround);
|
package/dist/cli/command.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const parse: (argv: string[]) => {
|
|
|
7
7
|
};
|
|
8
8
|
export declare const requestOf: (command: string, words: string[], flags: Record<string, string>) => RootRequest;
|
|
9
9
|
export declare const main: (argv: string[], env: Record<string, string | undefined>, print: (line: string) => void) => Promise<number>;
|
|
10
|
-
export declare const pilotRequests: (name: string, invitation: JsonObject, at
|
|
10
|
+
export declare const pilotRequests: (name: string, invitation: JsonObject, at?: string) => RootRequest[];
|
|
11
11
|
export declare const via: (pilot: string, far: RootRequest) => RootRequest;
|
|
12
12
|
export declare const farAnswer: (local: JsonObject) => JsonObject;
|
|
13
13
|
export declare const SERVING = -1;
|
package/dist/cli/command.js
CHANGED
|
@@ -11,9 +11,18 @@
|
|
|
11
11
|
// nervur module remove <module> a module it runs no more
|
|
12
12
|
// nervur modules what the catalogue runs
|
|
13
13
|
// nervur census [--ward W] the empty ask
|
|
14
|
+
// nervur sweep every place of the folder this
|
|
15
|
+
// harbor does not name, gone
|
|
16
|
+
// nervur export [--with-seed] the harbor as one JSON line, to
|
|
17
|
+
// carry it to another ground; with
|
|
18
|
+
// --with-seed the seed goes too
|
|
19
|
+
// nervur import <file> a carried harbor into an empty
|
|
20
|
+
// folder, `-` being this input
|
|
14
21
|
// nervur open <key> <kind> a faculty stands in the box ward
|
|
15
22
|
// nervur host <ward> [--seed S] a hosted ward stands
|
|
16
|
-
// nervur route <ward pk> <
|
|
23
|
+
// nervur route <ward pk> <address... | none> where a ward is reached, in order
|
|
24
|
+
// nervur reach <address...> where this harbor is reached, as
|
|
25
|
+
// every invitation it gives says
|
|
17
26
|
// nervur boot [--ward W] <key> <kind> a being stands
|
|
18
27
|
// nervur unboot [--ward W] <being> a being goes
|
|
19
28
|
// nervur public [--ward W] <key | none> the being a stranger reaches
|
|
@@ -21,8 +30,9 @@
|
|
|
21
30
|
// nervur ask [--ward W] [being] [method] [args] a being, asked as the owner
|
|
22
31
|
// nervur own [--ward W] <id> an invitation for an owner
|
|
23
32
|
// nervur disown [--ward W] <id> an owner no more
|
|
24
|
-
// nervur pilot <name> <invitation>
|
|
25
|
-
// pilot being of the box ward
|
|
33
|
+
// nervur pilot <name> <invitation> [address] a far ward held as its owner, by a
|
|
34
|
+
// pilot being of the box ward, at
|
|
35
|
+
// the address or the invitation's at
|
|
26
36
|
// nervur <command> --via <name> ... the command asked of the far ward
|
|
27
37
|
// that pilot holds, as its owner
|
|
28
38
|
//
|
|
@@ -31,11 +41,14 @@
|
|
|
31
41
|
// ward, and a pilot holds one ward, so --via takes no --ward. The exit is 1
|
|
32
42
|
// when the answer is an error.
|
|
33
43
|
import { existsSync } from 'node:fs';
|
|
34
|
-
import {
|
|
44
|
+
import { readFile } from 'node:fs/promises';
|
|
35
45
|
import { join, resolve } from 'node:path';
|
|
36
|
-
import {
|
|
37
|
-
|
|
46
|
+
import { harborDir } from '../node/index.js';
|
|
47
|
+
import { carry, isServed, land, open, request, serve } from './harbor.js';
|
|
48
|
+
const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
|
|
38
49
|
const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
|
|
50
|
+
// The one flag that stands alone, since what it names is a yes.
|
|
51
|
+
const ALONE = 'with-seed';
|
|
39
52
|
export const parse = (argv) => {
|
|
40
53
|
const flags = {};
|
|
41
54
|
const words = [];
|
|
@@ -44,6 +57,8 @@ export const parse = (argv) => {
|
|
|
44
57
|
const name = arg.startsWith('--') ? arg.slice(2) : undefined;
|
|
45
58
|
if (name === undefined)
|
|
46
59
|
words.push(arg);
|
|
60
|
+
else if (name === ALONE)
|
|
61
|
+
flags[name] = 'yes';
|
|
47
62
|
else if (!FLAGS.has(name) || argv[i + 1] === undefined)
|
|
48
63
|
throw new Error(`--${name} is no flag with a value`);
|
|
49
64
|
else
|
|
@@ -52,6 +67,13 @@ export const parse = (argv) => {
|
|
|
52
67
|
const [command, ...rest] = words;
|
|
53
68
|
return { ...(command === undefined ? {} : { command }), words: rest, flags };
|
|
54
69
|
};
|
|
70
|
+
// What this command was handed on its input, for `import -`.
|
|
71
|
+
const standingIn = async () => {
|
|
72
|
+
let text = '';
|
|
73
|
+
for await (const chunk of process.stdin)
|
|
74
|
+
text += chunk.toString('utf8');
|
|
75
|
+
return text;
|
|
76
|
+
};
|
|
55
77
|
const none = (word) => (word === 'none' ? null : word);
|
|
56
78
|
const json = (word) => (word === undefined ? undefined : JSON.parse(word));
|
|
57
79
|
const catalogue = (method, args = {}) => ({ method: 'ask', args: { being: 'catalogue', method, args } });
|
|
@@ -74,6 +96,8 @@ export const requestOf = (command, words, flags) => {
|
|
|
74
96
|
}
|
|
75
97
|
case 'modules':
|
|
76
98
|
return (need(0), catalogue('modules'));
|
|
99
|
+
case 'sweep':
|
|
100
|
+
return (need(0), { method: 'sweep', args: {} });
|
|
77
101
|
case 'census':
|
|
78
102
|
return (need(0), at);
|
|
79
103
|
case 'open': {
|
|
@@ -85,9 +109,15 @@ export const requestOf = (command, words, flags) => {
|
|
|
85
109
|
return { method: 'host', args: { ward: ward, ...(flags.seed === undefined ? {} : { seed: flags.seed }) } };
|
|
86
110
|
}
|
|
87
111
|
case 'route': {
|
|
88
|
-
const [ward, where] =
|
|
89
|
-
|
|
112
|
+
const [ward, ...where] = words;
|
|
113
|
+
if (ward === undefined || where.length === 0)
|
|
114
|
+
throw new Error('route takes a ward pk and its addresses, or none');
|
|
115
|
+
return { method: 'route', args: { ward, at: where.length === 1 && where[0] === 'none' ? null : where } };
|
|
90
116
|
}
|
|
117
|
+
case 'reach':
|
|
118
|
+
if (words.length === 0)
|
|
119
|
+
throw new Error('reach takes the addresses this harbor is reached at');
|
|
120
|
+
return { method: 'reach', args: { at: words } };
|
|
91
121
|
case 'boot': {
|
|
92
122
|
const [key, kind] = need(2);
|
|
93
123
|
return { ...at, method: 'boot', args: { key: key, class: kind } };
|
|
@@ -124,7 +154,7 @@ export const main = async (argv, env, print) => {
|
|
|
124
154
|
const { command, words, flags } = parse(argv);
|
|
125
155
|
if (command === undefined)
|
|
126
156
|
throw new Error(USAGE);
|
|
127
|
-
const dir = flags.dir ?? env
|
|
157
|
+
const dir = flags.dir ?? harborDir(env);
|
|
128
158
|
const out = (answer) => {
|
|
129
159
|
print(JSON.stringify(answer));
|
|
130
160
|
return failed(answer) ? 1 : 0;
|
|
@@ -134,15 +164,27 @@ export const main = async (argv, env, print) => {
|
|
|
134
164
|
throw new Error('init takes no argument');
|
|
135
165
|
if (existsSync(join(dir, 'seed')))
|
|
136
166
|
return out({ error: `a harbor stands at ${dir}` });
|
|
137
|
-
const
|
|
167
|
+
const harbor = await open(dir);
|
|
138
168
|
try {
|
|
139
169
|
const owner = await harbor.root('own', { id: 'owner' });
|
|
140
170
|
return out({ pk: harbor.box.pk, owner: owner });
|
|
141
171
|
}
|
|
142
172
|
finally {
|
|
143
|
-
await
|
|
173
|
+
await harbor.close();
|
|
144
174
|
}
|
|
145
175
|
}
|
|
176
|
+
if (command === 'export' || command === 'import') {
|
|
177
|
+
if (words.length !== (command === 'import' ? 1 : 0))
|
|
178
|
+
throw new Error(`${command} takes ${command === 'import' ? 'one file' : 'no argument'}`);
|
|
179
|
+
if (flags.via !== undefined)
|
|
180
|
+
throw new Error(`${command} reads this folder, so it takes no --via`);
|
|
181
|
+
if (await isServed(dir))
|
|
182
|
+
throw new Error(`a harbor is served from ${dir}: stop it first`);
|
|
183
|
+
if (command === 'export')
|
|
184
|
+
return out((await carry(dir, flags[ALONE] !== undefined)));
|
|
185
|
+
const text = words[0] === '-' ? await standingIn() : await readFile(words[0], 'utf8');
|
|
186
|
+
return out({ landed: await land(dir, JSON.parse(text)) });
|
|
187
|
+
}
|
|
146
188
|
if (command === 'serve') {
|
|
147
189
|
if (words.length > 0)
|
|
148
190
|
throw new Error('serve takes no argument');
|
|
@@ -150,12 +192,12 @@ export const main = async (argv, env, print) => {
|
|
|
150
192
|
const stop = () => void served.close().then(() => process.exit(0));
|
|
151
193
|
process.once('SIGINT', stop);
|
|
152
194
|
process.once('SIGTERM', stop);
|
|
153
|
-
print(JSON.stringify({ pk: served.pk, at: served.at, root: served.root }));
|
|
195
|
+
print(JSON.stringify({ pk: served.pk, at: served.at, listening: served.listening, root: served.root }));
|
|
154
196
|
return SERVING;
|
|
155
197
|
}
|
|
156
198
|
if (command === 'pilot') {
|
|
157
|
-
if (words.length !== 3)
|
|
158
|
-
throw new Error('pilot takes
|
|
199
|
+
if (words.length !== 2 && words.length !== 3)
|
|
200
|
+
throw new Error('pilot takes a name, an invitation, and an address where the invitation carries none');
|
|
159
201
|
const [name, invitation, at] = words;
|
|
160
202
|
const held = JSON.parse(invitation);
|
|
161
203
|
for (const req of pilotRequests(name, held, at)) {
|
|
@@ -174,9 +216,10 @@ export const main = async (argv, env, print) => {
|
|
|
174
216
|
throw new Error('--via takes no --ward');
|
|
175
217
|
return out(farAnswer(await request(dir, via(flags.via, requestOf(command, words, flags)))));
|
|
176
218
|
};
|
|
177
|
-
// The root requests that hold a far ward: its route
|
|
219
|
+
// The root requests that hold a far ward: its route where one is named,
|
|
220
|
+
// else the take learns it from the invitation's `at`; the pilot; the take.
|
|
178
221
|
export const pilotRequests = (name, invitation, at) => [
|
|
179
|
-
{ method: 'route', args: { ward: invitation.ward ?? null, at } },
|
|
222
|
+
...(at === undefined ? [] : [{ method: 'route', args: { ward: invitation.ward ?? null, at: [at] } }]),
|
|
180
223
|
{ method: 'boot', args: { key: name, class: 'org.nervur.pilot' } },
|
|
181
224
|
{ method: 'ask', args: { being: name, method: 'hold', args: { invitation } } },
|
|
182
225
|
];
|