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.
Files changed (131) hide show
  1. package/README.md +130 -8
  2. package/dist/browser/index.d.ts +35 -0
  3. package/dist/browser/index.js +188 -0
  4. package/dist/browser/worker.d.ts +12 -0
  5. package/dist/browser/worker.js +40 -0
  6. package/dist/browser.d.ts +1 -0
  7. package/dist/browser.js +7 -0
  8. package/dist/cli/command.d.ts +1 -1
  9. package/dist/cli/command.js +59 -16
  10. package/dist/cli/harbor.d.ts +9 -11
  11. package/dist/cli/harbor.js +81 -123
  12. package/dist/contract/index.d.ts +2 -1
  13. package/dist/contract/index.js +3 -7
  14. package/dist/edge/index.d.ts +57 -0
  15. package/dist/edge/index.js +306 -0
  16. package/dist/edge.d.ts +1 -0
  17. package/dist/edge.js +8 -0
  18. package/dist/folder/index.d.ts +4 -0
  19. package/dist/folder/index.js +57 -14
  20. package/dist/harbor/carrying.d.ts +17 -0
  21. package/dist/harbor/carrying.js +38 -0
  22. package/dist/harbor/catalogue.d.ts +2 -0
  23. package/dist/harbor/catalogue.js +28 -20
  24. package/dist/harbor/dock.d.ts +22 -3
  25. package/dist/harbor/dock.js +69 -13
  26. package/dist/harbor/harbor.d.ts +14 -4
  27. package/dist/harbor/harbor.js +144 -15
  28. package/dist/harbor/index.d.ts +6 -3
  29. package/dist/harbor/index.js +4 -1
  30. package/dist/harbor/package.d.ts +2 -0
  31. package/dist/harbor/package.js +15 -0
  32. package/dist/harbor/probe.d.ts +18 -0
  33. package/dist/harbor/probe.js +15 -0
  34. package/dist/harbor/registry.d.ts +1 -0
  35. package/dist/harbor/registry.js +11 -0
  36. package/dist/harbor/relay.d.ts +15 -0
  37. package/dist/harbor/relay.js +387 -0
  38. package/dist/harbor/root-line.js +4 -2
  39. package/dist/harbor/terrain.d.ts +12 -0
  40. package/dist/harbor/terrain.js +19 -0
  41. package/dist/http/index.d.ts +4 -0
  42. package/dist/http/index.js +156 -0
  43. package/dist/http/websocket.d.ts +29 -0
  44. package/dist/http/websocket.js +133 -0
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.js +8 -2
  47. package/dist/line/answer.d.ts +8 -0
  48. package/dist/line/answer.js +60 -0
  49. package/dist/line/frame.d.ts +34 -0
  50. package/dist/line/frame.js +81 -0
  51. package/dist/line/ground.d.ts +3 -0
  52. package/dist/line/ground.js +9 -0
  53. package/dist/line/index.d.ts +5 -0
  54. package/dist/line/index.js +9 -0
  55. package/dist/line/push.d.ts +7 -0
  56. package/dist/line/push.js +92 -0
  57. package/dist/line/web.d.ts +30 -0
  58. package/dist/line/web.js +181 -0
  59. package/dist/node/index.d.ts +32 -0
  60. package/dist/node/index.js +164 -0
  61. package/dist/node.d.ts +1 -0
  62. package/dist/node.js +7 -0
  63. package/dist/pointer/bodies.d.ts +2 -0
  64. package/dist/pointer/bodies.js +10 -0
  65. package/dist/pointer/world.d.ts +7 -1
  66. package/dist/pointer/world.js +6 -0
  67. package/dist/quo/address.d.ts +19 -0
  68. package/dist/quo/address.js +84 -0
  69. package/dist/quo/door.d.ts +1 -1
  70. package/dist/quo/door.js +4 -2
  71. package/dist/quo/index.d.ts +1 -0
  72. package/dist/quo/index.js +1 -0
  73. package/dist/quo/invitation.d.ts +1 -0
  74. package/dist/quo/invitation.js +9 -5
  75. package/dist/tcp/frame.d.ts +0 -4
  76. package/dist/tcp/frame.js +0 -10
  77. package/dist/tcp/index.d.ts +8 -2
  78. package/dist/tcp/index.js +123 -27
  79. package/dist/ward/index.d.ts +2 -2
  80. package/dist/ward/index.js +1 -1
  81. package/dist/ward/stance.d.ts +2 -0
  82. package/dist/ward/stance.js +3 -1
  83. package/dist/ward/ward-being.d.ts +2 -1
  84. package/dist/ward/ward-being.js +1 -1
  85. package/dist/ward/ward.d.ts +7 -0
  86. package/dist/ward/ward.js +6 -0
  87. package/package.json +21 -2
  88. package/src/browser/index.ts +207 -0
  89. package/src/browser/worker.ts +66 -0
  90. package/src/browser.ts +9 -0
  91. package/src/cli/command.ts +53 -17
  92. package/src/cli/harbor.ts +82 -116
  93. package/src/contract/index.ts +11 -8
  94. package/src/edge/index.ts +359 -0
  95. package/src/edge.ts +10 -0
  96. package/src/folder/index.ts +57 -12
  97. package/src/harbor/carrying.ts +56 -0
  98. package/src/harbor/catalogue.ts +32 -17
  99. package/src/harbor/dock.ts +88 -17
  100. package/src/harbor/harbor.ts +146 -17
  101. package/src/harbor/index.ts +6 -3
  102. package/src/harbor/package.ts +16 -0
  103. package/src/harbor/probe.ts +34 -0
  104. package/src/harbor/registry.ts +11 -0
  105. package/src/harbor/relay.ts +399 -0
  106. package/src/harbor/root-line.ts +4 -1
  107. package/src/harbor/terrain.ts +28 -2
  108. package/src/http/index.ts +150 -0
  109. package/src/http/websocket.ts +124 -0
  110. package/src/index.ts +35 -2
  111. package/src/line/answer.ts +59 -0
  112. package/src/line/frame.ts +88 -0
  113. package/src/line/ground.ts +19 -0
  114. package/src/line/index.ts +9 -0
  115. package/src/line/push.ts +112 -0
  116. package/src/line/web.ts +227 -0
  117. package/src/node/index.ts +170 -0
  118. package/src/node.ts +9 -0
  119. package/src/pointer/bodies.ts +11 -0
  120. package/src/pointer/world.ts +18 -2
  121. package/src/quo/address.ts +87 -0
  122. package/src/quo/door.ts +4 -2
  123. package/src/quo/index.ts +1 -0
  124. package/src/quo/invitation.ts +10 -6
  125. package/src/stand/stand.ts +60 -21
  126. package/src/tcp/index.ts +142 -31
  127. package/src/ward/index.ts +2 -2
  128. package/src/ward/stance.ts +6 -1
  129. package/src/ward/ward-being.ts +2 -2
  130. package/src/ward/ward.ts +16 -0
  131. 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 listener your harbor,
122
- and harbors in two processes, or a harbor and another kit, speak.
123
-
124
- Other carriers, storage beyond a folder, key custody, and every screen
125
- are `@nervur-org/dock`'s, as classes fulfilling these contracts.
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>' host:port --dir ./mine
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. With
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';
@@ -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);
@@ -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: string) => RootRequest[];
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;
@@ -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> <host:port | none> where a ward is reached
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> <host:port> a far ward held as its owner, by a
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 { homedir } from 'node:os';
44
+ import { readFile } from 'node:fs/promises';
35
45
  import { join, resolve } from 'node:path';
36
- import { open, request, serve } from './harbor.js';
37
- const USAGE = 'usage: nervur <init|serve|module|modules|census|open|host|route|boot|unboot|public|invite|ask|own|disown|pilot> [args] [--dir D] [--ward W] [--via V] [--seed S] [--host H] [--port P]';
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] = need(2);
89
- return { method: 'route', args: { ward: ward, at: none(where) } };
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.NERVUR_DIR ?? join(homedir(), '.nervur');
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 { harbor, carrier } = await open(dir);
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 carrier.close();
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 3 arguments');
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, the pilot, the take.
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
  ];