nervur 0.20.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The unpacking law, as scenes any terrain's bodies run: a harbor is one
3
+ // package. Only the seed and the terrain's bodies stand outside it, the
4
+ // terrain keeps sealed bytes it cannot read, and the seed opens the memory,
5
+ // the dock stands the catalogue, the catalogue loads the modules her cells
6
+ // name, the faculties and every ward stand from cells, and the dock lends.
7
+ // It names no runtime, so it bundles.
8
+ import { Being, Faculty } from '../being/index.js';
9
+ import { Memory } from '../contract/index.js';
10
+ import { copyPackage, rootLine } from '../harbor/index.js';
11
+ import { CryptoEntropy, HeldLoader, World } from '../pointer/index.js';
12
+ class Echoes extends Faculty {
13
+ static kind = 'org.example.echoes';
14
+ }
15
+ class Echo extends Echoes {
16
+ static kind = 'org.example.echo';
17
+ static asks = { echo: {} };
18
+ echo(args, asker) {
19
+ return { echoed: args.text ?? null, to: asker.id ?? null };
20
+ }
21
+ }
22
+ class Shop extends Being {
23
+ static kind = 'org.example.shop';
24
+ static cells = { sold: [] };
25
+ static asks = { buy: {} };
26
+ buy(args, asker) {
27
+ this.cells.sold.push(`${String(args.item)} to ${asker.id ?? 'nobody'}`);
28
+ return { bought: args.item ?? null };
29
+ }
30
+ }
31
+ class Customer extends Being {
32
+ static kind = 'org.example.customer';
33
+ static asks = { join: {}, buy: {}, echo: {}, spoil: {} };
34
+ spoil() {
35
+ try {
36
+ this.cells.when = new Date();
37
+ return { kept: true };
38
+ }
39
+ catch {
40
+ return { refused: true };
41
+ }
42
+ }
43
+ async join(args) {
44
+ return { joined: await this.stance.standings.take('shop', args.invitation) };
45
+ }
46
+ async buy(args) {
47
+ return { said: ((await this.stance.standings.get('shop')?.ask('buy', args)) ?? null) };
48
+ }
49
+ async echo(args) {
50
+ if (!this.stance.standings.get('echo'))
51
+ await this.stance.lend(Echoes, 'echo');
52
+ return { said: ((await this.stance.standings.get('echo')?.ask('echo', args)) ?? null) };
53
+ }
54
+ }
55
+ // One set of classes for every scene in a bundle, since a bundler renames
56
+ // a second class of the same name and a row resolves a class by its name.
57
+ export const classes = [Echo, Shop, Customer];
58
+ export const law = { module: 'org.example.law', version: '1', classes };
59
+ // A harbor of the world whose catalogue runs the law's module.
60
+ export const standOne = async (world, name, parts = {}) => {
61
+ const harbor = await world.harbor(name, [law], parts);
62
+ await harbor.root('ask', { being: 'catalogue', method: 'add', args: { from: law.module } });
63
+ return harbor;
64
+ };
65
+ const CATALOGUE = { catalogue: { class: 'org.nervur.catalogue', public: false, absent: false } };
66
+ const NONE = { wards: {}, routes: {} };
67
+ // Every byte a memory is handed, seen from outside it.
68
+ class Watched extends Memory {
69
+ inner;
70
+ seen = [];
71
+ constructor(inner) {
72
+ super();
73
+ this.inner = inner;
74
+ }
75
+ read(place) {
76
+ return this.inner.read(place);
77
+ }
78
+ write(place, entries) {
79
+ for (const [name, bytes] of entries)
80
+ this.seen.push({ place, name, bytes: bytes && bytes.slice() });
81
+ return this.inner.write(place, entries);
82
+ }
83
+ places() {
84
+ return this.inner.places();
85
+ }
86
+ forget(place) {
87
+ return this.inner.forget(place);
88
+ }
89
+ }
90
+ const utf8 = (text) => new TextEncoder().encode(text);
91
+ const holds = (bytes, part) => {
92
+ outer: for (let at = 0; at + part.length <= bytes.length; at += 1) {
93
+ for (let k = 0; k < part.length; k += 1)
94
+ if (bytes[at + k] !== part[k])
95
+ continue outer;
96
+ return true;
97
+ }
98
+ return false;
99
+ };
100
+ const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
101
+ const census = async (harbor, ward) => {
102
+ const out = (await (ward === undefined ? harbor.root() : harbor.ward(ward).root()));
103
+ return out.notes;
104
+ };
105
+ // Acme hosts a shop; home opens an echo, hosts alice under a drawn seed and
106
+ // bob under a named one, and alice takes the shop and lends the echo.
107
+ const stand = async (world, t, bodies, seed, expect) => {
108
+ const acme = await standOne(world, 'acme', bodies.acme);
109
+ const home = await standOne(world, 'home', bodies.home);
110
+ expect.same(home.names, [], `${t.name}: a new harbor hosts nobody`);
111
+ expect.same(await home.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
112
+ expect.same(await home.root('host', { ward: 'alice' }), { hosted: 'alice' });
113
+ expect.same(await home.root('host', { ward: 'bob', seed }), { hosted: 'bob' });
114
+ expect.same(await home.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
115
+ expect.same(await acme.root('host', { ward: 'shop' }), { hosted: 'shop' });
116
+ expect.same(await acme.ward('shop').root('boot', { key: 'shop', class: 'org.example.shop' }), { booted: 'shop' });
117
+ const invitation = (await acme.ward('shop').root('invite', { being: 'shop', id: 'alice' }));
118
+ const me = async (method, args = {}) => (await world.get('home').ward('alice').root('ask', { being: 'me', method, args })).answer;
119
+ expect.same(await me('join', { invitation }), { joined: 'shop' });
120
+ expect.same(await me('buy', { item: 'tea' }), { said: { bought: 'tea' } });
121
+ expect.same(await me('echo', { text: 'hi' }), { said: { echoed: 'hi', to: 'lent:1' } });
122
+ };
123
+ export const lawScenes = (t, expect) => [
124
+ [
125
+ `law on ${t.name}: empty memory unpacks a box ward holding the dock and her catalogue, and it wakes the same`,
126
+ async () => {
127
+ const bodies = t.make();
128
+ const world = new World();
129
+ const harbor = await world.harbor('h', [], bodies);
130
+ const pk = harbor.box.pk;
131
+ expect.same(await census(harbor), { pk, beings: CATALOGUE, failed: NONE });
132
+ const again = await world.restart('h', t.again(bodies));
133
+ expect.equal(again.box.pk, pk, 'the seed is the box ward');
134
+ expect.same(await census(again), { pk, beings: CATALOGUE, failed: NONE });
135
+ },
136
+ ],
137
+ [
138
+ `law on ${t.name}: a restart without a module the catalogue names stands, its beings absent, and wakes them when it returns`,
139
+ async () => {
140
+ const bodies = t.make();
141
+ const world = new World();
142
+ const harbor = await standOne(world, 'h', bodies);
143
+ expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
144
+ expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
145
+ const lost = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([]) });
146
+ expect.same(lost.names, ['w'], 'the rest of the harbor stands');
147
+ expect.same((await census(lost)).beings.echo?.absent, true);
148
+ expect.same(await lost.root('ask', { being: 'catalogue', method: 'modules' }), {
149
+ answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `not found at ${law.module}` } },
150
+ });
151
+ const again = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law]) });
152
+ expect.same((await census(again)).beings.echo?.absent, false);
153
+ expect.same(await again.root('ask', { being: 'catalogue', method: 'modules' }), { answer: { [law.module]: { version: '1', running: '1', from: law.module } } });
154
+ },
155
+ ],
156
+ [
157
+ `law on ${t.name}: a module that does not load is removed only once no being stands on the kinds kept for it`,
158
+ async () => {
159
+ const bodies = t.make();
160
+ const world = new World();
161
+ const harbor = await standOne(world, 'h', bodies);
162
+ expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
163
+ const lost = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([]) });
164
+ const remove = () => lost.root('ask', { being: 'catalogue', method: 'remove', args: { module: law.module } });
165
+ expect.same(await remove(), { answer: { error: 'a being stands on this module' } });
166
+ expect.same(await lost.root('unboot', { being: 'echo' }), { unbooted: 'echo' });
167
+ expect.same(await remove(), { answer: { removed: law.module } });
168
+ const after = await world.restart('h', t.again(bodies));
169
+ expect.same(await after.root('ask', { being: 'catalogue', method: 'modules' }), { answer: {} });
170
+ },
171
+ ],
172
+ [
173
+ `law on ${t.name}: a restart where a module splits a kind runs the modules before it, and the one alone stays out`,
174
+ async () => {
175
+ const bodies = t.make();
176
+ const world = new World();
177
+ const twin = { module: 'org.example.twin', version: '1', classes: [] };
178
+ const harbor = await world.harbor('h', [law, twin], bodies);
179
+ for (const from of [law.module, twin.module])
180
+ await harbor.root('ask', { being: 'catalogue', method: 'add', args: { from } });
181
+ expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
182
+ const split = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law, { ...twin, version: '2', classes }]) });
183
+ expect.same((await census(split)).beings.echo?.absent, false, 'the module before it runs');
184
+ const modules = (await split.root('ask', { being: 'catalogue', method: 'modules' })).answer;
185
+ expect.same([modules[law.module]?.running, modules[twin.module]?.running], ['1', null]);
186
+ expect.equal(modules[twin.module]?.failed, `kind org.example.echo is in modules ${law.module} and ${twin.module}`);
187
+ },
188
+ ],
189
+ [
190
+ `law on ${t.name}: a hosted ward whose place does not open fails alone, and a box ward whose place does not open fails the harbor`,
191
+ async () => {
192
+ const bodies = t.make();
193
+ const watched = new Watched(bodies.memory);
194
+ const world = new World();
195
+ const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
196
+ expect.same(await harbor.root('host', { ward: 'good' }), { hosted: 'good' });
197
+ const from = watched.seen.length;
198
+ expect.same(await harbor.root('host', { ward: 'bad' }), { hosted: 'bad' });
199
+ const box = watched.seen[0].place;
200
+ const bad = watched.seen.slice(from).find((s) => s.place !== box).place;
201
+ const places = [bad, box];
202
+ const spoil = async (place) => {
203
+ const [[name, bytes]] = [...(await watched.inner.read(place))];
204
+ const changed = bytes.slice();
205
+ changed[0] = changed[0] ^ 1;
206
+ await watched.inner.write(place, new Map([[name, changed]]));
207
+ };
208
+ await spoil(places[0]);
209
+ const again = await world.restart('h', t.again(bodies));
210
+ expect.same(again.names, ['good']);
211
+ expect.same((await census(again)).failed, { wards: { bad: 'an entry the package did not seal' }, routes: {} });
212
+ expect.same(await rootLine(again, { ward: 'bad' }), { error: 'the ward did not stand', reason: 'an entry the package did not seal' });
213
+ expect.same(await again.root('host', { ward: 'bad' }), { error: 'no such ward to host' }, 'its name and seed stay kept');
214
+ await spoil(places[1]);
215
+ let refused = '';
216
+ await world.restart('h', t.again(bodies)).catch((e) => (refused = e.message));
217
+ expect.equal(refused, 'an entry the package did not seal');
218
+ },
219
+ ],
220
+ [
221
+ `law on ${t.name}: a package carried to another memory stands the same harbor under the same seed`,
222
+ async () => {
223
+ const here = t.make();
224
+ const there = t.make();
225
+ const world = new World();
226
+ const harbor = await standOne(world, 'here', here);
227
+ expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
228
+ expect.same(await harbor.root('host', { ward: 'alice' }), { hosted: 'alice' });
229
+ expect.same(await harbor.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
230
+ const pks = { box: harbor.box.pk, alice: harbor.ward('alice').pk };
231
+ const carried = await copyPackage(here.memory, there.memory);
232
+ expect.equal(carried.length, 2, 'the box ward and alice');
233
+ const moved = await world.harbor('there', [law], { custody: here.custody, memory: there.memory, loader: new HeldLoader([law]) });
234
+ expect.same({ box: moved.box.pk, alice: moved.ward('alice').pk }, pks);
235
+ expect.same((await census(moved, 'alice')).beings, { me: { class: 'org.example.customer', public: false, absent: false } });
236
+ expect.same((await moved.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'moved' } })).answer, { said: { echoed: 'moved', to: 'lent:1' } });
237
+ const stranger = await world.harbor('stranger', [law], { custody: t.make().custody, memory: there.memory, loader: new HeldLoader([law]) });
238
+ expect.same(stranger.names, [], 'the bytes carried say nothing without the seed');
239
+ },
240
+ ],
241
+ [
242
+ `law on ${t.name}: the root sweeps every place the dock does not name, and nothing it does`,
243
+ async () => {
244
+ const bodies = t.make();
245
+ const world = new World();
246
+ const harbor = await standOne(world, 'h', bodies);
247
+ expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
248
+ const mine = (await bodies.memory.places()).sort();
249
+ expect.equal(mine.length, 2, 'the box ward and the ward it hosts');
250
+ await bodies.memory.write('ff'.repeat(16), new Map([['ab', new Uint8Array([1, 2, 3])]]));
251
+ expect.same(await harbor.root('sweep'), { swept: ['ff'.repeat(16)] });
252
+ expect.same((await bodies.memory.places()).sort(), mine, 'what the dock names stays');
253
+ expect.same(await harbor.root('sweep'), { swept: [] });
254
+ const again = await world.restart('h', t.again(bodies));
255
+ expect.same(again.names, ['w']);
256
+ expect.same(await again.ward('w').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
257
+ },
258
+ ],
259
+ [
260
+ `law on ${t.name}: a restart stands the dock, its faculties, every hosted ward and its beings, and lends, with nobody asking`,
261
+ async () => {
262
+ const bodies = { acme: t.make(), home: t.make() };
263
+ const world = new World();
264
+ const seed = hex(new CryptoEntropy().draw(32));
265
+ await stand(world, t, bodies, seed, expect);
266
+ const before = world.get('home');
267
+ const pks = { box: before.box.pk, alice: before.ward('alice').pk, bob: before.ward('bob').pk, shop: world.get('acme').ward('shop').pk };
268
+ const beings = await census(before, 'alice');
269
+ const home = await world.restart('home', t.again(bodies.home));
270
+ const acme = await world.restart('acme', t.again(bodies.acme));
271
+ expect.same(home.names, ['alice', 'bob']);
272
+ expect.same(acme.names, ['shop']);
273
+ expect.same({ box: home.box.pk, alice: home.ward('alice').pk, bob: home.ward('bob').pk, shop: acme.ward('shop').pk }, pks);
274
+ expect.same(await census(home, 'alice'), beings);
275
+ expect.equal(home.box.partition.being('echo')?.class, 'org.example.echo');
276
+ const me = async (method, args = {}) => (await home.ward('alice').root('ask', { being: 'me', method, args })).answer;
277
+ expect.same(await me('buy', { item: 'cake' }), { said: { bought: 'cake' } });
278
+ expect.same(await me('echo', { text: 'again' }), { said: { echoed: 'again', to: 'lent:1' } });
279
+ expect.same(acme.ward('shop').partition.being('shop')?.cells, { sold: ['tea to alice', 'cake to alice'] });
280
+ },
281
+ ],
282
+ [
283
+ `law on ${t.name}: every root ask is a step a restart may follow, and each step wakes whole`,
284
+ async () => {
285
+ const bodies = t.make();
286
+ const world = new World();
287
+ await world.harbor('h', [law], bodies);
288
+ const step = async (ask, want) => {
289
+ expect.same(await ask(world.get('h')), want);
290
+ return world.restart('h', t.again(bodies));
291
+ };
292
+ await step((h) => h.root('ask', { being: 'catalogue', method: 'add', args: { from: law.module } }), { answer: { added: law.module, version: law.version } });
293
+ await step((h) => h.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
294
+ await step((h) => h.root('host', { ward: 'alice' }), { hosted: 'alice' });
295
+ await step((h) => h.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
296
+ const h = await step(async (x) => (await x.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'one' } })).answer, { said: { echoed: 'one', to: 'lent:1' } });
297
+ expect.same((await h.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'two' } })).answer, { said: { echoed: 'two', to: 'lent:1' } });
298
+ },
299
+ ],
300
+ [
301
+ `law on ${t.name}: the terrain keeps the seed and sealed bytes, and reads no name, cell or hosted seed`,
302
+ async () => {
303
+ const home = t.make();
304
+ const watched = new Watched(home.memory);
305
+ const world = new World();
306
+ const seed = hex(new CryptoEntropy().draw(32));
307
+ await stand(world, t, { acme: t.make(), home: { custody: home.custody, memory: watched } }, seed, expect);
308
+ const harbor = world.get('home');
309
+ const secrets = [seed, ...harbor.dock.hosted.map((h) => h.seed)].map(utf8);
310
+ // Names of five bytes or more: a shorter one turns up in random bytes
311
+ // of this size often enough to fail a run by chance.
312
+ const words = ['alice', 'org.example', 'hosted', 'lent:1', 'customer'].map(utf8);
313
+ expect.ok(watched.seen.length > 0, 'the harbor kept something');
314
+ for (const { place, name, bytes } of watched.seen) {
315
+ expect.ok(/^[0-9a-f]+$/.test(place) && /^[0-9a-f]+$/.test(name), 'names are the kit hex');
316
+ if (bytes === null)
317
+ continue;
318
+ for (const secret of secrets)
319
+ expect.ok(!holds(bytes, secret), 'no hosted seed in the clear');
320
+ for (const word of words)
321
+ expect.ok(!holds(bytes, word), 'no name or cell in the clear');
322
+ expect.ok(!holds(bytes, await home.custody.seed()), 'no harbor seed in memory');
323
+ }
324
+ },
325
+ ],
326
+ [
327
+ `law on ${t.name}: another seed finds nothing of the package, and a byte changed refuses to unpack`,
328
+ async () => {
329
+ const bodies = t.make();
330
+ const watched = new Watched(bodies.memory);
331
+ const world = new World();
332
+ const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
333
+ expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
334
+ const last = [...watched.seen].reverse().find((s) => s.bytes !== null && s.bytes.length > 0);
335
+ const stranger = await world.harbor('stranger', [law], { custody: t.make().custody, memory: watched });
336
+ expect.same(stranger.names, [], 'another seed opens another package');
337
+ const bytes = last.bytes.slice();
338
+ bytes[0] = bytes[0] ^ 1;
339
+ await watched.inner.write(last.place, new Map([[last.name, bytes]]));
340
+ let refused = false;
341
+ await world.restart('h', t.again(bodies)).catch(() => (refused = true));
342
+ expect.ok(refused, 'a changed byte does not unpack');
343
+ },
344
+ ],
345
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.20.3",
3
+ "version": "0.21.0",
4
4
  "description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is.",
5
5
  "keywords": [
6
6
  "quo",
@@ -46,13 +46,18 @@
46
46
  "./http": {
47
47
  "types": "./dist/http/index.d.ts",
48
48
  "default": "./dist/http/index.js"
49
+ },
50
+ "./proof": {
51
+ "types": "./dist/proof/index.d.ts",
52
+ "default": "./dist/proof/index.js"
49
53
  }
50
54
  },
51
55
  "scripts": {
52
56
  "build": "tsc -p tsconfig.build.json",
53
57
  "check": "tsc -p tsconfig.json && npm run build && node ../../test.mjs \"test/unit/*.test.ts\" \"test/e2e/*.test.ts\"",
54
58
  "deep": "node ../../test.mjs \"test/terrain/*.test.ts\"",
55
- "cover": "node --test --test-timeout=60000 --experimental-test-coverage --test-coverage-exclude=\"**/dist/**\" --test-coverage-exclude=\"**/test/**\" --test-coverage-exclude=\"**/src/stand/**\" --test-coverage-exclude=\"**/src/browser/**\" --test-coverage-exclude=\"**/src/edge/**\" --test-coverage-exclude=\"../../quo/**\" --test-coverage-lines=99 --test-coverage-branches=93 --test-coverage-functions=95 \"test/unit/*.test.ts\" \"test/e2e/*.test.ts\"",
59
+ "cover": "node test/cover.mjs",
60
+ "mutate": "node test/mutate.mjs",
56
61
  "prepublishOnly": "test \"$NERVUR_GATED\" = 1 || { echo 'a publish is /release, from the root, on the human'\"'\"'s word' >&2; exit 1; }"
57
62
  },
58
63
  "engines": {
@@ -70,5 +75,8 @@
70
75
  "README.md",
71
76
  "LICENSE",
72
77
  "NOTICE"
73
- ]
78
+ ],
79
+ "devDependencies": {
80
+ "wrangler": "^4.134.0"
81
+ }
74
82
  }
@@ -23,6 +23,10 @@
23
23
  // printed, for the worker's
24
24
  // NERVUR_SEED, and its sealed
25
25
  // package written to the file
26
+ // nervur edge dev <file> [--port P] the harbor packed to the file and
27
+ // run by the worker's own wrangler
28
+ // dev, from this folder, at
29
+ // http://127.0.0.1:P/quo
26
30
  // nervur open <key> <kind> a faculty stands in the box ward
27
31
  // nervur host <ward> [--seed S] a hosted ward stands
28
32
  // nervur route <ward pk> <address... | none> where a ward is reached, in order
@@ -53,8 +57,12 @@ import type { RootRequest } from '../harbor/index.ts';
53
57
  import { WEB_OPENING } from '../line/index.ts';
54
58
  import { harborDir, isFile } from '../node/index.ts';
55
59
  import { readAt, webAddress } from '../quo/index.ts';
60
+ import { edgeDev } from './edge.ts';
56
61
  import { carry, isServed, land, open, pack, request, serve } from './harbor.ts';
57
62
 
63
+ // Where `edge dev` answers unless --port names another, wrangler's own.
64
+ const EDGE_PORT = 8787;
65
+
58
66
  const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
59
67
  const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
60
68
  // The one flag that stands alone, since what it names is a yes.
@@ -182,10 +190,16 @@ export const main = async (argv: string[], env: Record<string, string | undefine
182
190
  return out({ landed: await land(dir, JSON.parse(text)) });
183
191
  }
184
192
  if (command === 'edge') {
185
- if (words[0] !== 'pack' || words.length !== 2) throw new Error('edge takes pack and a file');
186
- if (flags.via !== undefined) throw new Error('edge pack reads this folder, so it takes no --via');
193
+ if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 2) throw new Error('edge takes pack or dev, and a file');
194
+ if (flags.via !== undefined) throw new Error(`edge ${words[0]} reads this folder, so it takes no --via`);
187
195
  if (await isServed(dir)) throw new Error(`a harbor is served from ${dir}: stop it first`);
188
- return out(await pack(dir, words[1]!));
196
+ if (words[0] === 'pack') return out(await pack(dir, words[1]!));
197
+ const dev = await edgeDev(dir, words[1]!, process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port));
198
+ const stop = (): void => void dev.close().then(() => process.exit(0));
199
+ process.once('SIGINT', stop);
200
+ process.once('SIGTERM', stop);
201
+ print(JSON.stringify({ at: dev.at, places: dev.places }));
202
+ return SERVING;
189
203
  }
190
204
  if (command === 'serve') {
191
205
  if (words.length > 0) throw new Error('serve takes no argument');
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // `nervur edge dev`: an edge harbor on this machine, as it will stand on
3
+ // Cloudflare. The harbor of a folder is packed into the file the worker
4
+ // imports, and the worker's own `wrangler dev` runs it, in the folder the
5
+ // command runs from, with the seed handed as `NERVUR_SEED` through the
6
+ // environment so no listing of processes shows it. The object's storage is
7
+ // kept under `.nervur-edge` there, so a harbor landed once stands again
8
+ // across restarts, as it does on Cloudflare.
9
+ //
10
+ // wrangler is the worker's tool and never the kit's: the one who deploys
11
+ // an edge has it already, and `edge dev` finds it where npm put it.
12
+ import { spawn, type ChildProcess } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+ import { pack } from './harbor.ts';
15
+
16
+ export const EDGE_STATE = '.nervur-edge';
17
+ const READY_TRIES = 240;
18
+ const READY_EVERY = 250;
19
+
20
+ export type EdgeDev = { readonly at: string; readonly places: number; close(): Promise<void> };
21
+
22
+ const answers = (url: string): Promise<boolean> =>
23
+ fetch(url).then(
24
+ () => true,
25
+ () => false,
26
+ );
27
+
28
+ // Resolved once wrangler answers at `url`, rejected once it exits or never
29
+ // answers.
30
+ const ready = async (child: ChildProcess, url: string, said: () => string): Promise<void> => {
31
+ let gone = false;
32
+ child.once('exit', () => (gone = true));
33
+ for (let i = 0; i < READY_TRIES; i += 1) {
34
+ if (gone) throw new Error(`wrangler dev exited: ${said()}`);
35
+ if (await answers(url)) return;
36
+ await new Promise((r) => setTimeout(r, READY_EVERY));
37
+ }
38
+ throw new Error(`wrangler dev never answered at ${url}: ${said()}`);
39
+ };
40
+
41
+ export const edgeDev = async (dir: string, file: string, cwd: string, port: number): Promise<EdgeDev> => {
42
+ const { seed, places } = (await pack(dir, file)) as { seed: string; places: number };
43
+ const child = spawn('npx', ['--no-install', 'wrangler', 'dev', '--ip', '127.0.0.1', '--port', String(port), '--persist-to', join(cwd, EDGE_STATE), '--log-level', 'warn'], {
44
+ cwd,
45
+ env: { ...process.env, NERVUR_SEED: seed, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
46
+ stdio: ['ignore', 'ignore', 'pipe'],
47
+ detached: true,
48
+ });
49
+ let err = '';
50
+ child.stderr!.on('data', (chunk: Buffer) => (err = `${err}${chunk.toString('utf8')}`.slice(-4000)));
51
+ const at = `http://127.0.0.1:${String(port)}/quo`;
52
+ const close = (): Promise<void> =>
53
+ new Promise((done) => {
54
+ if (child.exitCode !== null) return done();
55
+ child.once('exit', () => done());
56
+ process.kill(-child.pid!, 'SIGTERM');
57
+ });
58
+ try {
59
+ await ready(child, at, () => err);
60
+ } catch (e) {
61
+ await close();
62
+ throw e;
63
+ }
64
+ return { at, places, close };
65
+ };
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ export {
47
47
 
48
48
  // The pointer dock: a whole world in one process.
49
49
  export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
50
- export { NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
50
+ export { answerPost, NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
51
51
 
52
52
  // The ground that fits everywhere, tried after any a platform entry adds.
53
53
  addGround(neutralGround);
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The suites of the bodies, Custody, Memory, Entropy and Loader, as scenes
3
+ // with no platform under them, so Node's runner, every engine and a
4
+ // stranger's own runner hold a body to the same assertions.
5
+ import { Custody, Entropy, Loader, Memory } from '../contract/index.ts';
6
+ import type { Expect } from './expect.ts';
7
+
8
+ export type Scene = [string, () => Promise<void>];
9
+
10
+ const bytes = (...values: number[]): Uint8Array => Uint8Array.from(values);
11
+ // A place as plain values, in name order, so two can be compared as JSON.
12
+ const plain = (place: ReadonlyMap<string, Uint8Array>): [string, number[]][] => [...place].map(([n, b]): [string, number[]] => [n, [...b]]).sort(([a], [b]) => (a < b ? -1 : 1));
13
+
14
+ // `make` gives two bodies over one keeping, as two runs would build them,
15
+ // and a fresh keeping each call.
16
+ export const custodyScenes = (name: string, make: () => [Custody, Custody], expect: Expect): Scene[] => [
17
+ [
18
+ `[contract] ${name} fulfils Custody: one seed of thirty-two bytes, the same every time`,
19
+ async () => {
20
+ const [custody, again] = make();
21
+ expect.ok(custody instanceof Custody);
22
+ const seed = await custody.seed();
23
+ expect.equal(seed.length, 32);
24
+ const first = [...seed];
25
+ seed.fill(0);
26
+ expect.same([...(await custody.seed())], first, 'a seed handed out is a copy');
27
+ expect.same([...(await again.seed())], first, 'another body over the keeping holds the same');
28
+ expect.ok([...(await make()[0].seed())].join() !== first.join(), 'another keeping holds another seed');
29
+ const [fresh, rival] = make();
30
+ const [a, b] = await Promise.all([fresh.seed(), rival.seed()]);
31
+ expect.same([...a], [...b], 'two first asks at once keep one seed');
32
+ },
33
+ ],
34
+ ];
35
+
36
+ // `cut` is a body whose next keep stops partway, as a run that dies inside
37
+ // one does, and `fresh` the memory a new run opens on the same keeping. A
38
+ // body no crash can cut short, holding its places in one object, names
39
+ // none.
40
+ export type Cut = { cut: Memory; fresh: Memory };
41
+
42
+ export const memoryScenes = (name: string, make: () => [Memory, Memory], expect: Expect, half?: () => Cut): Scene[] => [
43
+ [
44
+ `[contract] ${name} fulfils Memory: it names its places, and forgets one whole`,
45
+ async () => {
46
+ const [memory, again] = make();
47
+ expect.same(await memory.places(), []);
48
+ await memory.write('aa', new Map([['01', bytes(1)]]));
49
+ await memory.write('bb', new Map([['01', bytes(2)], ['02', bytes(3)]]));
50
+ expect.same([...(await again.places())].sort(), ['aa', 'bb']);
51
+ await memory.forget('aa');
52
+ expect.same(await again.places(), ['bb']);
53
+ expect.same(plain(await again.read('aa')), []);
54
+ await memory.forget('aa');
55
+ await memory.write('bb', new Map([['01', null], ['02', null]]));
56
+ expect.same(await again.places(), [], 'a place holding nothing is no place');
57
+ },
58
+ ],
59
+ ...(half
60
+ ? [
61
+ [
62
+ `[contract] ${name} fulfils Memory: a keep that stops partway is whole or is nothing`,
63
+ async () => {
64
+ const { cut, fresh } = half();
65
+ await cut.write('aa', new Map([['01', bytes(1)], ['02', bytes(2)]])).catch(() => undefined);
66
+ const before = plain(await fresh.read('aa'));
67
+ await cut.write('aa', new Map([['01', bytes(9)], ['02', null], ['03', bytes(3)]])).catch(() => undefined);
68
+ const after = JSON.stringify(plain(await fresh.read('aa')));
69
+ const whole = JSON.stringify(plain(new Map([['01', bytes(9)], ['03', bytes(3)]])));
70
+ expect.ok(after === JSON.stringify(before) || after === whole, `a keep cut short left ${after}`);
71
+ },
72
+ ] satisfies Scene,
73
+ ]
74
+ : []),
75
+ [
76
+ `[contract] ${name} fulfils Memory: a place read again holds what was kept, and nothing else`,
77
+ async () => {
78
+ const [memory, again] = make();
79
+ expect.ok(memory instanceof Memory);
80
+ expect.same(plain(await memory.read('aa')), []);
81
+ const written = bytes(1, 2, 3);
82
+ await memory.write('aa', new Map([['01', written], ['02', bytes(4)]]));
83
+ written[0] = 9;
84
+ await memory.write('bb', new Map([['01', bytes(5)]]));
85
+ await memory.write('aa', new Map([['02', null], ['03', bytes()], ['04', null]]));
86
+ const read = await again.read('aa');
87
+ expect.same(plain(read), [['01', [1, 2, 3]], ['03', []]]);
88
+ expect.ok(read.get('01') instanceof Uint8Array, 'an entry is bytes');
89
+ read.get('01')![0] = 7;
90
+ expect.same([...(await memory.read('aa')).get('01')!], [1, 2, 3], 'an entry handed out is a copy');
91
+ expect.same(plain(await again.read('bb')), [['01', [5]]]);
92
+ expect.same(plain(await again.read('cc')), []);
93
+ },
94
+ ],
95
+ ];
96
+
97
+ export const entropyScenes = (name: string, make: () => Entropy, expect: Expect): Scene[] => [
98
+ [
99
+ `[contract] ${name} fulfils Entropy: bytes of the length asked, never the same twice`,
100
+ () => {
101
+ const entropy = make();
102
+ expect.ok(entropy instanceof Entropy);
103
+ expect.equal(entropy.draw(0).length, 0);
104
+ expect.equal(entropy.drawer(33).length, 33);
105
+ expect.ok([...entropy.draw(32)].join() !== [...entropy.draw(32)].join(), 'two draws differ');
106
+ return Promise.resolve();
107
+ },
108
+ ],
109
+ ];
110
+
111
+ // `make` gives a loader, a name it finds a module by, and one it finds
112
+ // nothing by.
113
+ export const loaderScenes = (name: string, make: () => { loader: Loader; found: string; missing: string }, expect: Expect): Scene[] => [
114
+ [
115
+ `[contract] ${name} fulfils Loader: a module by its name, found again by the name it gives back, and nothing else`,
116
+ async () => {
117
+ const { loader, found, missing } = make();
118
+ expect.ok(loader instanceof Loader);
119
+ const loaded = await loader.load(found);
120
+ expect.ok(loaded !== null, `nothing found at ${found}`);
121
+ expect.equal(typeof loaded!.module, 'string');
122
+ expect.equal(typeof loaded!.version, 'string');
123
+ expect.ok(Array.isArray(loaded!.classes) && loaded!.classes.every((C) => typeof C === 'function'), 'the classes are classes');
124
+ const again = await loader.load(loaded!.from);
125
+ expect.same(again && { module: again.module, version: again.version, from: again.from }, { module: loaded!.module, version: loaded!.version, from: loaded!.from });
126
+ expect.equal(await loader.load(missing), null);
127
+ },
128
+ ],
129
+ ];