nervur 0.22.2-3 → 0.22.2-5
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/AUTHORING.md +74 -1
- package/README.md +36 -0
- package/dist/browser/browser-ground.js +13 -3
- package/dist/browser/locked-custody.d.ts +2 -0
- package/dist/browser/locked-custody.js +4 -0
- package/dist/house/rows.js +32 -15
- package/dist/node/cli.js +2 -1
- package/dist/node/file-memory.js +2 -7
- package/dist/node/hand.d.ts +8 -1
- package/dist/node/hand.js +25 -5
- package/dist/node/ledger-memory.js +3 -2
- package/dist/node/node-ground.js +2 -2
- package/dist/node/sync-folder.d.ts +6 -0
- package/dist/node/sync-folder.js +18 -0
- package/package.json +2 -2
package/AUTHORING.md
CHANGED
|
@@ -144,6 +144,12 @@ and TypeScript reads from it the types of her cells and her needs.
|
|
|
144
144
|
| `roles` | named tests over the asker and her cells |
|
|
145
145
|
| `state` | a function of the being that names her current state |
|
|
146
146
|
| `asks` | one entry per method she answers |
|
|
147
|
+
| `view` | markup as text over her asks, which a screen renders |
|
|
148
|
+
|
|
149
|
+
A view is data, at most 64 KiB, carried in her describe to every asker.
|
|
150
|
+
It holds no script, and a renderer loads nothing it names, so a view
|
|
151
|
+
neither acts nor tracks. The library defines no markup: a renderer
|
|
152
|
+
speaks its own.
|
|
147
153
|
|
|
148
154
|
Every field is optional but `kind` and `asks`. A class with no `state`
|
|
149
155
|
has one state, named `ready`. The kind is how the house finds her code
|
|
@@ -195,7 +201,7 @@ nothing lands.
|
|
|
195
201
|
| `this.house.alarm({ at, ask, args, key })` | one of her asks, at that time |
|
|
196
202
|
| `this.house.cancelAlarm({ key })` | an alarm removed |
|
|
197
203
|
| `this.<need>.<method>({…}, { reply? })` | an awaited call, or an effect |
|
|
198
|
-
| `this.held(id, Need).<ask>({…}, { reply? })` | the same, on a standing |
|
|
204
|
+
| `this.held(id, Need).<ask>({…}, { reply?, after? })` | the same, on a standing, and a watch where `after` is given |
|
|
199
205
|
| `this.standings` | `list()`, `note(id, notes)` and `drop(id)` |
|
|
200
206
|
| `this.handle(ask, { bind?, notes?, once?, expires? })` | a handle to one of her asks |
|
|
201
207
|
| `this.invite(id, { notes?, expires? })` | a new occupant, as a handle |
|
|
@@ -239,6 +245,29 @@ one receiver leave one at a time, in the order she called them.
|
|
|
239
245
|
Asks to one being run one at a time, in the order they arrive. A
|
|
240
246
|
`readOnly` ask runs beside that queue, on the cells last landed.
|
|
241
247
|
|
|
248
|
+
### Watching an answer
|
|
249
|
+
|
|
250
|
+
A watch is a `readOnly` ask asked with `after`, the answer you already
|
|
251
|
+
hold. The house answers at once where the answer differs. Where it is
|
|
252
|
+
the same, the house holds the ask and runs it again each time an ask of
|
|
253
|
+
that being lands. It answers the first answer that differs, or the same
|
|
254
|
+
one when the wait runs out. So a chat, an order's status or a dashboard
|
|
255
|
+
is a loop of watches, and nothing polls.
|
|
256
|
+
|
|
257
|
+
Through the hand, pass `after` beside the method, as the answer you
|
|
258
|
+
hold: `{ result: [...] }`. From a being, pass the result she holds:
|
|
259
|
+
`this.held(room, Messages).messages({}, { after: seen })`, inside a
|
|
260
|
+
`readOnly` ask of her own, so she stays free while she waits. Only a
|
|
261
|
+
`readOnly` ask is watched, and a watch on anything else is refused where
|
|
262
|
+
she makes it.
|
|
263
|
+
|
|
264
|
+
A watch moves only on what its asker could read, since it runs as that
|
|
265
|
+
asker. One asker holds one watch on one ask with the same args, and a
|
|
266
|
+
second answers the first at once. A watch across a door holds its
|
|
267
|
+
relation until it answers, since Quo moves a relation one ask at a time.
|
|
268
|
+
So watch a far being through a handle to the watched ask alone, a
|
|
269
|
+
relation of its own, and ask everything else on your other standing.
|
|
270
|
+
|
|
242
271
|
### Relations
|
|
243
272
|
|
|
244
273
|
An **occupant** is someone who may ask her. She mints one with
|
|
@@ -719,6 +748,50 @@ A user unit stops when its user logs out, unless lingering is enabled
|
|
|
719
748
|
with `loginctl enable-linger`. On macOS, the agent goes to
|
|
720
749
|
`~/Library/LaunchAgents/` and is started with `launchctl bootstrap`.
|
|
721
750
|
|
|
751
|
+
### In a page
|
|
752
|
+
|
|
753
|
+
`BrowserGround.open({ recipe })` from `nervur/browser` runs the same
|
|
754
|
+
houses in a page. Every tab and the service worker of one origin share
|
|
755
|
+
one ground: the one holding the Web Lock runs it, and the others reach
|
|
756
|
+
its hand over a `BroadcastChannel`. When it closes, the next opens the
|
|
757
|
+
ground from the same storage. `hand` answers the same three requests
|
|
758
|
+
the command sends: `describe`, a faculty's method, and an ask of a being
|
|
759
|
+
in a named house.
|
|
760
|
+
|
|
761
|
+
Its bodies are the browser's. Seeds are sealed under an AES key that
|
|
762
|
+
IndexedDB holds unextractable, and memory is IndexedDB, named
|
|
763
|
+
`indexeddb` in an entry. Classes load from the page's own origin through
|
|
764
|
+
the `origin` body, `{ body: 'origin', at: '/house/index.js' }`, and a
|
|
765
|
+
path off the origin is refused. The recipe is the object you pass: its
|
|
766
|
+
faculties, made by a function, and any custom body.
|
|
767
|
+
|
|
768
|
+
Any script on the origin can use the ground's keys, so the ground is
|
|
769
|
+
whoever serves the origin's script. Give it an origin of its own, serve
|
|
770
|
+
nothing a stranger wrote there, and set a strict content security
|
|
771
|
+
policy. The page and the house's module must share one copy of
|
|
772
|
+
`nervur/being`, since a house knows a class by a mark that module gives.
|
|
773
|
+
A bundler's shared chunk or an import map gives the one copy.
|
|
774
|
+
|
|
775
|
+
A service worker opens the ground the same way, on a push, where no tab
|
|
776
|
+
runs it. It may not `import()`, so hand it the modules it imported
|
|
777
|
+
itself: `platform: { load: (href) => modules[new URL(href).pathname] }`.
|
|
778
|
+
|
|
779
|
+
### In an app
|
|
780
|
+
|
|
781
|
+
`AppGround.open({ shell })` from `nervur/app` is a BrowserGround in the
|
|
782
|
+
app's web view, on two interfaces the shell fills in native code:
|
|
783
|
+
|
|
784
|
+
- `NativeSecrets`: `get(name)` and `set(name, value)`, text kept by the
|
|
785
|
+
iOS Keychain or the Android Keystore on this device alone.
|
|
786
|
+
- `NativeStore`: `get(key)`, `keys(prefix)`, and `swap(writes, expect)`,
|
|
787
|
+
which lands every write only where each key in `expect` still holds
|
|
788
|
+
what it names, and answers whether it landed.
|
|
789
|
+
|
|
790
|
+
The library holds the rest. Each house's seed goes into the secret
|
|
791
|
+
store, and each memory into the native store, which the system never
|
|
792
|
+
evicts as it may a web view's storage. A push token reaches the ground
|
|
793
|
+
through a faculty your recipe makes.
|
|
794
|
+
|
|
722
795
|
## What the house guarantees
|
|
723
796
|
|
|
724
797
|
1. **Every need is covered, or she is absent.** An absent being answers
|
package/README.md
CHANGED
|
@@ -107,6 +107,39 @@ the house's seed, its ledger and its record in `state/`, readable by
|
|
|
107
107
|
you alone. Keep that folder secret: a house opens on no other seed.
|
|
108
108
|
`npx nervur help` lists everything the ground offers.
|
|
109
109
|
|
|
110
|
+
## A ground in a page
|
|
111
|
+
|
|
112
|
+
The same house runs in a browser. Bundle the house's module and serve it
|
|
113
|
+
on your origin, beside a page that opens the ground.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// page.ts
|
|
117
|
+
import { BrowserGround } from 'nervur/browser';
|
|
118
|
+
|
|
119
|
+
// Every tab of this origin joins one ground, and the tab holding its lock runs it.
|
|
120
|
+
const ground = await BrowserGround.open();
|
|
121
|
+
|
|
122
|
+
// The house's code is a module on this origin, and its rows live in IndexedDB.
|
|
123
|
+
await ground.hand({
|
|
124
|
+
faculty: 'houses',
|
|
125
|
+
method: 'add',
|
|
126
|
+
args: { name: 'main', memory: { body: 'indexeddb' }, classes: { body: 'origin', at: '/house/index.js' } },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log(await ground.hand({ house: 'main', method: 'hello', args: { name: 'Ada' } }));
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Every tab of the origin reaches the one ground, and when the tab running
|
|
133
|
+
it closes, the next opens it from the same storage. Its seeds are sealed
|
|
134
|
+
under a key the browser never hands out. Any script on the origin can
|
|
135
|
+
use them, so give the ground an origin of its own, with a strict content
|
|
136
|
+
security policy. The page and the house's module must share one copy of
|
|
137
|
+
`nervur/being`, as a bundler's shared chunk gives.
|
|
138
|
+
|
|
139
|
+
An app on a phone opens the same ground with `AppGround` from
|
|
140
|
+
`nervur/app`, on the Keychain or the Keystore and a store the system
|
|
141
|
+
keeps. `AUTHORING.md` shows both.
|
|
142
|
+
|
|
110
143
|
## Entries
|
|
111
144
|
|
|
112
145
|
| Entry | For |
|
|
@@ -114,6 +147,9 @@ you alone. Keep that folder secret: a house opens on no other seed.
|
|
|
114
147
|
| `nervur/being` | writing a being: `Being`, `s`, `need`, `Args`, `Result` |
|
|
115
148
|
| `nervur` | any engine: `Ground`, `House` and the bodies they take |
|
|
116
149
|
| `nervur/node` | a ground on Node: `NodeGround`, the `nervur` command, and its bodies |
|
|
150
|
+
| `nervur/browser` | a ground in a page or its service worker: `BrowserGround` and its bodies |
|
|
151
|
+
| `nervur/app` | a ground in a phone's app: `AppGround`, and the two interfaces its shell fills |
|
|
152
|
+
| `nervur/serve` | a faculty's program in JavaScript: `serve` |
|
|
117
153
|
| `nervur/bench` | tests: `Bench`, `BenchGround`, `FakeNetwork` and the fakes of every body |
|
|
118
154
|
|
|
119
155
|
## License
|
|
@@ -40,6 +40,8 @@ export class BrowserGround {
|
|
|
40
40
|
// Asks sent to another page's ground, by id, with the page they went to.
|
|
41
41
|
#pending = new Map();
|
|
42
42
|
#ground;
|
|
43
|
+
// The custody and the memories its boot opened, each closed when it closes.
|
|
44
|
+
#opened = [];
|
|
43
45
|
#persisted = false;
|
|
44
46
|
#leader;
|
|
45
47
|
#waiting = [];
|
|
@@ -178,8 +180,13 @@ export class BrowserGround {
|
|
|
178
180
|
const name = this.#name;
|
|
179
181
|
const platform = this.#platform;
|
|
180
182
|
const recipe = this.#options.recipe ?? {};
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
+
// Every store it opens is kept, so its close lets each go before the lock passes.
|
|
184
|
+
const kept = (opened) => {
|
|
185
|
+
this.#opened.push(opened);
|
|
186
|
+
return opened;
|
|
187
|
+
};
|
|
188
|
+
const custody = kept(await platform.custody(`${name}-custody`));
|
|
189
|
+
const memory = kept(await platform.memory(`${name}-ground`));
|
|
183
190
|
this.#persisted = await platform.persist().catch(() => false);
|
|
184
191
|
// It only dials, so it writes no address into an invitation.
|
|
185
192
|
const web = new WebCarry({ allowPrivate: this.#options.allowPrivate === true });
|
|
@@ -188,7 +195,7 @@ export class BrowserGround {
|
|
|
188
195
|
for (const [faculty, made] of Object.entries(recipe.faculties?.() ?? {}))
|
|
189
196
|
faculties[faculty] = await made;
|
|
190
197
|
const bodies = {
|
|
191
|
-
memory: joined({ indexeddb: ({ house }) => platform.memory(`${name}-house-${house}`) }, recipe.bodies?.memory),
|
|
198
|
+
memory: joined({ indexeddb: async ({ house }) => kept(await platform.memory(`${name}-house-${house}`)) }, recipe.bodies?.memory),
|
|
192
199
|
classes: joined({
|
|
193
200
|
origin: ({ args }) => {
|
|
194
201
|
if (typeof args.at !== 'string')
|
|
@@ -209,6 +216,9 @@ export class BrowserGround {
|
|
|
209
216
|
const ground = this.#ground;
|
|
210
217
|
this.#ground = undefined;
|
|
211
218
|
await ground?.close();
|
|
219
|
+
// Its IndexedDB connections go with it, so the next ground in this page opens on none of its.
|
|
220
|
+
for (const opened of this.#opened.splice(0))
|
|
221
|
+
opened.close?.();
|
|
212
222
|
this.#release?.();
|
|
213
223
|
this.#orphan(() => true);
|
|
214
224
|
for (const resolve of this.#waiting)
|
|
@@ -19,6 +19,8 @@ export declare class LockedCustody implements Custody {
|
|
|
19
19
|
constructor(shelf: Shelf, subtle?: SubtleCrypto);
|
|
20
20
|
/** The custody of the origin, on a shelf in IndexedDB named `name`. */
|
|
21
21
|
static open(name?: string, factory?: IDBFactory): Promise<LockedCustody>;
|
|
22
|
+
/** Its shelf let go, where the shelf holds a connection. */
|
|
23
|
+
close(): void;
|
|
22
24
|
keys({ house }: {
|
|
23
25
|
house: string;
|
|
24
26
|
}): Promise<Keys>;
|
|
@@ -54,6 +54,10 @@ export class LockedCustody {
|
|
|
54
54
|
static async open(name = 'nervur-custody', factory = indexedDB) {
|
|
55
55
|
return new LockedCustody(await IndexedDbShelf.open(name, factory));
|
|
56
56
|
}
|
|
57
|
+
/** Its shelf let go, where the shelf holds a connection. */
|
|
58
|
+
close() {
|
|
59
|
+
this.#shelf.close?.();
|
|
60
|
+
}
|
|
57
61
|
keys({ house }) {
|
|
58
62
|
if (!NAME.test(house))
|
|
59
63
|
return Promise.reject(new TypeError(`no house is named ${house}`));
|
package/dist/house/rows.js
CHANGED
|
@@ -12,6 +12,8 @@ export class Rows {
|
|
|
12
12
|
#placeNames = new Map();
|
|
13
13
|
/** The last write holding each row, which the next one to hold it waits for. */
|
|
14
14
|
#holds = new Map();
|
|
15
|
+
// How many times each row's cache has moved, so a read overtaken by a write never lands behind it.
|
|
16
|
+
#moves = new Map();
|
|
15
17
|
#landed;
|
|
16
18
|
constructor(memory, crypto, tools, names, key) {
|
|
17
19
|
this.#memory = memory;
|
|
@@ -33,22 +35,36 @@ export class Rows {
|
|
|
33
35
|
}
|
|
34
36
|
return place;
|
|
35
37
|
}
|
|
38
|
+
// A row's cache moved, by a write that landed or one refused: a read in flight across it is behind.
|
|
39
|
+
#moved(place) {
|
|
40
|
+
this.#moves.set(place, (this.#moves.get(place) ?? 0) + 1);
|
|
41
|
+
}
|
|
42
|
+
#forget(place) {
|
|
43
|
+
this.#moved(place);
|
|
44
|
+
this.#held.delete(place);
|
|
45
|
+
}
|
|
36
46
|
async #read(place) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
for (;;) {
|
|
48
|
+
const cached = this.#held.get(place);
|
|
49
|
+
if (cached !== undefined)
|
|
50
|
+
return cached;
|
|
51
|
+
const move = this.#moves.get(place) ?? 0;
|
|
52
|
+
const { entries, version } = await this.#memory.read({ place });
|
|
53
|
+
const sealed = entries[ENTRY];
|
|
54
|
+
let held = { row: null, text: null, version };
|
|
55
|
+
if (sealed !== undefined) {
|
|
56
|
+
const opened = await this.#crypto.open(this.#key, sealed.subarray(0, 12), sealed.subarray(12), this.#tools.utf8(place));
|
|
57
|
+
const text = opened === null ? null : this.#tools.text(opened);
|
|
58
|
+
if (text === null)
|
|
59
|
+
throw new Error('a row does not open under these keys');
|
|
60
|
+
held = { row: JSON.parse(text), text, version };
|
|
61
|
+
}
|
|
62
|
+
// A write that moved the row while this read was in flight wins, and this read looks again.
|
|
63
|
+
if ((this.#moves.get(place) ?? 0) !== move)
|
|
64
|
+
continue;
|
|
65
|
+
this.#held.set(place, held);
|
|
66
|
+
return held;
|
|
49
67
|
}
|
|
50
|
-
this.#held.set(place, held);
|
|
51
|
-
return held;
|
|
52
68
|
}
|
|
53
69
|
/** A row as it stands, or `null`. */
|
|
54
70
|
async get(place) {
|
|
@@ -152,10 +168,11 @@ export class Rows {
|
|
|
152
168
|
}
|
|
153
169
|
if (versions === null) {
|
|
154
170
|
for (const place of Object.keys(expect))
|
|
155
|
-
this.#
|
|
171
|
+
this.#forget(place);
|
|
156
172
|
return false;
|
|
157
173
|
}
|
|
158
174
|
for (const [place, held] of landed) {
|
|
175
|
+
this.#moved(place);
|
|
159
176
|
this.#held.set(place, { ...held, version: versions[place] ?? null });
|
|
160
177
|
this.#landed?.(place, held.row);
|
|
161
178
|
}
|
package/dist/node/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { connect } from 'node:net';
|
|
|
20
20
|
import { basename, join, resolve } from 'node:path';
|
|
21
21
|
import { createInterface } from 'node:readline';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { handAt } from './hand.js';
|
|
23
24
|
import { NodeGround } from './node-ground.js';
|
|
24
25
|
import { notifyReady } from './notify.js';
|
|
25
26
|
const USAGE = 'nervur up <folder> | service <folder> | [--at <socket>] help | ask <house> [--id <being>] [<method> [args]] | <faculty> [<method> [args]]';
|
|
@@ -127,7 +128,7 @@ const service = (folder) => {
|
|
|
127
128
|
};
|
|
128
129
|
const words = process.argv.slice(2);
|
|
129
130
|
// The hand: named, or the one a ground in this folder serves.
|
|
130
|
-
let at = process.env.NERVUR_HAND ?? (existsSync(join('state', 'hand')) ?
|
|
131
|
+
let at = process.env.NERVUR_HAND ?? (existsSync(process.platform === 'win32' ? 'state' : join('state', 'hand')) ? handAt('state') : undefined);
|
|
131
132
|
if (words[0] === '--at') {
|
|
132
133
|
at = words[1] ?? fail('--at names no socket');
|
|
133
134
|
words.splice(0, 2);
|
package/dist/node/file-memory.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
6
6
|
import { dirname } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { syncFolder } from './sync-folder.js';
|
|
8
9
|
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
|
9
10
|
const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
|
|
10
11
|
export class FileMemory {
|
|
@@ -80,12 +81,6 @@ export class FileMemory {
|
|
|
80
81
|
await file.close();
|
|
81
82
|
}
|
|
82
83
|
await rename(temporary, this.#path);
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
await directory.sync();
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
await directory.close();
|
|
89
|
-
}
|
|
84
|
+
await syncFolder(folder);
|
|
90
85
|
}
|
|
91
86
|
}
|
package/dist/node/hand.d.ts
CHANGED
|
@@ -21,9 +21,16 @@ export interface HandRequest {
|
|
|
21
21
|
/** The answer the owner holds, which makes a `readOnly` ask a watch. */
|
|
22
22
|
readonly after?: Answer;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Where a ground's hand stands for its state folder: a socket inside it,
|
|
26
|
+
* or on Windows a named pipe, which has no place in a folder, named for
|
|
27
|
+
* the folder's path.
|
|
28
|
+
*/
|
|
29
|
+
export declare const handAt: (state: string, platform?: NodeJS.Platform) => string;
|
|
24
30
|
/**
|
|
25
31
|
* Serves `ask` at `path`: a house's own hand, or a ground's, which also
|
|
26
32
|
* reads `house` and changes its record. A socket left there by a process
|
|
27
|
-
* that is gone is replaced.
|
|
33
|
+
* that is gone is replaced. A named pipe on Windows is open to its user
|
|
34
|
+
* alone, as the operating system makes it, and leaves nothing behind.
|
|
28
35
|
*/
|
|
29
36
|
export declare const serveHand: (ask: (request: HandRequest) => Promise<unknown>, path: string) => Promise<Hand>;
|
package/dist/node/hand.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The hand on a local socket
|
|
3
|
-
// line out, in order.
|
|
2
|
+
// The hand on a local socket, or on Windows a named pipe: one JSON object
|
|
3
|
+
// a line in, one answer a line out, in order. It is open to the ground's
|
|
4
|
+
// user alone.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
4
6
|
import { chmod } from 'node:fs/promises';
|
|
5
7
|
import { createServer } from 'node:net';
|
|
8
|
+
import { join, resolve as absolute } from 'node:path';
|
|
6
9
|
import { createInterface } from 'node:readline';
|
|
7
10
|
import { gone } from './gone.js';
|
|
8
11
|
const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -46,16 +49,29 @@ const serve = (ask, socket) => {
|
|
|
46
49
|
// A local socket's path fits the kernel's address: 103 bytes on macOS and
|
|
47
50
|
// the BSDs, 107 on Linux, with the closing zero beyond.
|
|
48
51
|
const LONGEST = process.platform === 'linux' ? 107 : 103;
|
|
52
|
+
const PIPE = /^\\\\[.?]\\pipe\\/;
|
|
53
|
+
/**
|
|
54
|
+
* Where a ground's hand stands for its state folder: a socket inside it,
|
|
55
|
+
* or on Windows a named pipe, which has no place in a folder, named for
|
|
56
|
+
* the folder's path.
|
|
57
|
+
*/
|
|
58
|
+
export const handAt = (state, platform = process.platform) => platform === 'win32' ? `\\\\.\\pipe\\nervur-${createHash('sha256').update(absolute(state).toLowerCase()).digest('hex').slice(0, 16)}` : join(state, 'hand');
|
|
49
59
|
/**
|
|
50
60
|
* Serves `ask` at `path`: a house's own hand, or a ground's, which also
|
|
51
61
|
* reads `house` and changes its record. A socket left there by a process
|
|
52
|
-
* that is gone is replaced.
|
|
62
|
+
* that is gone is replaced. A named pipe on Windows is open to its user
|
|
63
|
+
* alone, as the operating system makes it, and leaves nothing behind.
|
|
53
64
|
*/
|
|
54
65
|
export const serveHand = async (ask, path) => {
|
|
66
|
+
if (PIPE.test(path))
|
|
67
|
+
return listen(ask, path, false);
|
|
55
68
|
const length = Buffer.byteLength(path);
|
|
56
69
|
if (length > LONGEST)
|
|
57
70
|
throw new Error(`a socket's path is at most ${LONGEST} bytes here, and ${path} is ${length}: serve the hand from a shorter folder`);
|
|
58
71
|
await gone(path);
|
|
72
|
+
return listen(ask, path, true);
|
|
73
|
+
};
|
|
74
|
+
const listen = async (ask, path, file) => {
|
|
59
75
|
const server = createServer((socket) => serve(ask, socket));
|
|
60
76
|
const sockets = new Set();
|
|
61
77
|
server.on('connection', (socket) => {
|
|
@@ -64,6 +80,8 @@ export const serveHand = async (ask, path) => {
|
|
|
64
80
|
});
|
|
65
81
|
await new Promise((resolve, reject) => {
|
|
66
82
|
server.once('error', reject);
|
|
83
|
+
if (!file)
|
|
84
|
+
return void server.listen(path, () => resolve());
|
|
67
85
|
// Closed to everyone from the first moment, then opened to the user.
|
|
68
86
|
const mask = process.umask(0o177);
|
|
69
87
|
try {
|
|
@@ -73,13 +91,15 @@ export const serveHand = async (ask, path) => {
|
|
|
73
91
|
process.umask(mask);
|
|
74
92
|
}
|
|
75
93
|
});
|
|
76
|
-
|
|
94
|
+
if (file)
|
|
95
|
+
await chmod(path, 0o600);
|
|
77
96
|
return {
|
|
78
97
|
close: async () => {
|
|
79
98
|
for (const socket of sockets)
|
|
80
99
|
socket.destroy();
|
|
81
100
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
82
|
-
|
|
101
|
+
if (file)
|
|
102
|
+
await gone(path);
|
|
83
103
|
},
|
|
84
104
|
};
|
|
85
105
|
};
|
|
@@ -9,6 +9,7 @@ import { createHash } from 'node:crypto';
|
|
|
9
9
|
import { mkdir, open, readFile, rename, truncate } from 'node:fs/promises';
|
|
10
10
|
import { dirname } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { syncFolder } from './sync-folder.js';
|
|
12
13
|
const GENESIS = { n: 0, hash: '0'.repeat(64) };
|
|
13
14
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
14
15
|
const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
|
|
@@ -103,7 +104,7 @@ export class LedgerMemory {
|
|
|
103
104
|
const temporary = `${this.#witness}.${process.pid}.tmp`;
|
|
104
105
|
await synced(temporary, 'w', (handle) => handle.writeFile(JSON.stringify(head)));
|
|
105
106
|
await rename(temporary, this.#witness);
|
|
106
|
-
await
|
|
107
|
+
await syncFolder(dirname(this.#witness));
|
|
107
108
|
}
|
|
108
109
|
async read({ place }) {
|
|
109
110
|
const held = (await this.#open()).places.get(place);
|
|
@@ -135,7 +136,7 @@ export class LedgerMemory {
|
|
|
135
136
|
try {
|
|
136
137
|
await synced(this.#path, 'a', (handle) => handle.appendFile(`${raw}\n`));
|
|
137
138
|
if (line.n === 1)
|
|
138
|
-
await
|
|
139
|
+
await syncFolder(dirname(this.#path));
|
|
139
140
|
if (this.#witness !== undefined)
|
|
140
141
|
await this.#attest(head);
|
|
141
142
|
}
|
package/dist/node/node-ground.js
CHANGED
|
@@ -20,7 +20,7 @@ import { WebCarry } from '../bodies/web-carry.js';
|
|
|
20
20
|
import { Ground } from '../ground/ground.js';
|
|
21
21
|
import { FolderCustody, KeychainCustody } from './custody.js';
|
|
22
22
|
import { FolderClasses } from './folder-classes.js';
|
|
23
|
-
import { serveHand } from './hand.js';
|
|
23
|
+
import { handAt, serveHand } from './hand.js';
|
|
24
24
|
import { serveHttp } from './http.js';
|
|
25
25
|
import { LedgerMemory } from './ledger-memory.js';
|
|
26
26
|
import { takeLock } from './lock.js';
|
|
@@ -127,7 +127,7 @@ export class NodeGround {
|
|
|
127
127
|
// 6. Hook: HTTP chains the web carry and every faculty's handler, and the hand takes its socket.
|
|
128
128
|
if (httpPort !== undefined)
|
|
129
129
|
http = await serveHttp({ port: httpPort, host: bind }, [web, ...ground.handlers]);
|
|
130
|
-
const hand = env.NERVUR_HAND ??
|
|
130
|
+
const hand = env.NERVUR_HAND ?? handAt(state);
|
|
131
131
|
const served = await serveHand((request) => ground.hand(request), hand);
|
|
132
132
|
return new NodeGround({ ground, hand, tcp, lock, served, http });
|
|
133
133
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes a file's creation or renaming in `folder` durable. On POSIX the
|
|
3
|
+
* folder is synced. Windows opens no folder for syncing, and NTFS journals
|
|
4
|
+
* a creation and a rename itself, so there it does nothing.
|
|
5
|
+
*/
|
|
6
|
+
export declare const syncFolder: (folder: string, platform?: NodeJS.Platform) => Promise<void>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
/**
|
|
4
|
+
* Makes a file's creation or renaming in `folder` durable. On POSIX the
|
|
5
|
+
* folder is synced. Windows opens no folder for syncing, and NTFS journals
|
|
6
|
+
* a creation and a rename itself, so there it does nothing.
|
|
7
|
+
*/
|
|
8
|
+
export const syncFolder = async (folder, platform = process.platform) => {
|
|
9
|
+
if (platform === 'win32')
|
|
10
|
+
return;
|
|
11
|
+
const handle = await open(folder, 'r');
|
|
12
|
+
try {
|
|
13
|
+
await handle.sync();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
await handle.close();
|
|
17
|
+
}
|
|
18
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nervur",
|
|
3
|
-
"version": "0.22.2-
|
|
3
|
+
"version": "0.22.2-5",
|
|
4
4
|
"description": "Nervur's kit of Quo: write beings and faculties, and open the house that holds them on any ground.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
70
70
|
"prepack": "npm run build",
|
|
71
71
|
"check": "tsc -p tsconfig.json && tsc -p tsconfig.pure.json && node test.mjs \"test/**/*.test.ts\"",
|
|
72
|
-
"deep": "tsc -p
|
|
72
|
+
"deep": "tsc -p deep && NERVUR_TEST_CAP=900000 node test.mjs \"deep/**/*.test.ts\"",
|
|
73
73
|
"prepublishOnly": "test \"$NERVUR_GATED\" = 1 || { echo 'a publish is /release, from the root, on the human'\"'\"'s word' >&2; exit 1; }"
|
|
74
74
|
},
|
|
75
75
|
"engines": {
|