nervur 0.20.4 → 0.21.1

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: 'bad is hosted already' }, '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.4",
3
+ "version": "0.21.1",
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,6 +46,10 @@
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": {
@@ -71,5 +75,8 @@
71
75
  "README.md",
72
76
  "LICENSE",
73
77
  "NOTICE"
74
- ]
78
+ ],
79
+ "devDependencies": {
80
+ "wrangler": "^4.134.0"
81
+ }
75
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
+ };
@@ -62,7 +62,8 @@ export type Module = { readonly module: string; readonly version: string; readon
62
62
  export type Loaded = Module & { readonly from: string };
63
63
 
64
64
  // Code, by the name a harbor keeps for it, or null where this loader has
65
- // none. Code never crosses a door and never lives in a cell, so it is the
65
+ // none. Code it has that does not load throws, saying why. Code never
66
+ // crosses a door and never lives in a cell, so it is the
66
67
  // one thing beside the seed a terrain hands in: a map, an import, a bundle.
67
68
  export abstract class Loader {
68
69
  abstract load(from: string): Promise<Loaded | null>;
@@ -67,10 +67,16 @@ export class Catalogue extends Faculty {
67
67
  // it. The rest stay out, each with its reason.
68
68
  async wake(): Promise<void> {
69
69
  for (const [module, row] of Object.entries(this.#rows)) {
70
- const loaded = await this.#harbor.load(row.from).catch(() => null);
70
+ const loaded = await this.#harbor.load(row.from).catch((e: Error) => e);
71
71
  const refused =
72
- loaded === null ? `not found at ${row.from}` : loaded.module !== module ? `${row.from} is module ${loaded.module}` : this.#harbor.check([...this.#running.values(), loaded]);
73
- if (refused === null) this.#running.set(module, loaded!);
72
+ loaded instanceof Error
73
+ ? loaded.message
74
+ : loaded === null
75
+ ? `not found at ${row.from}`
76
+ : loaded.module !== module
77
+ ? `${row.from} is module ${loaded.module}`
78
+ : this.#harbor.check([...this.#running.values(), loaded]);
79
+ if (refused === null) this.#running.set(module, loaded as Loaded);
74
80
  else this.#failed.set(module, refused);
75
81
  }
76
82
  this.#harbor.run([...this.#running.values()]);
@@ -78,7 +84,8 @@ export class Catalogue extends Faculty {
78
84
 
79
85
  async add(args: JsonObject): Promise<JsonObject> {
80
86
  if (!text(args.from)) return { error: 'from is text' };
81
- const loaded = await this.#harbor.load(args.from);
87
+ const loaded = await this.#harbor.load(args.from).catch((e: Error) => e);
88
+ if (loaded instanceof Error) return { error: loaded.message };
82
89
  if (loaded === null) return { error: 'no such module' };
83
90
  const running = new Map(this.#running);
84
91
  running.set(loaded.module, loaded);
@@ -158,7 +158,9 @@ export class Dock extends WardBeing implements Lender {
158
158
 
159
159
  async host(args: JsonObject): Promise<JsonObject> {
160
160
  const name = args.ward;
161
- if (!text(name) || name === BOX || this.hosted.some((h) => h.ward === name)) return { error: 'no such ward to host' };
161
+ if (!text(name)) return { error: 'ward is text' };
162
+ if (name === BOX) return { error: 'box is the box ward' };
163
+ if (this.hosted.some((h) => h.ward === name)) return { error: `${name} is hosted already` };
162
164
  if (args.seed !== undefined && !text(args.seed)) return { error: 'seed is text' };
163
165
  const invitation = await this.stance.occupants.invite(name, { hosted: true });
164
166
  if (invitation === null) return { error: 'not hosted' };
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);
package/src/node/index.ts CHANGED
@@ -50,8 +50,10 @@ export const isFile = (from: string): boolean => /^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:
50
50
  // Code from a file or an installed package: a module that exports
51
51
  // `module`, `version` and `classes`, found again by where it was found, a
52
52
  // file by its absolute path and a package by its name, resolved from each
53
- // folder of `bases` in order. What cannot be imported, or exports no
54
- // module, is none.
53
+ // folder of `bases` in order. What is not there, or exports no module, is
54
+ // none. Code that is there and does not import throws Node's reason, so
55
+ // whoever added it learns why; an ES module read as CommonJS says which
56
+ // field its package.json lacks.
55
57
  export class ImportLoader extends Loader {
56
58
  readonly #bases: readonly string[];
57
59
  constructor(bases: readonly string[] = [process.cwd()]) {
@@ -65,8 +67,13 @@ export class ImportLoader extends Loader {
65
67
  let exported: { module?: unknown; version?: unknown; classes?: unknown };
66
68
  try {
67
69
  exported = (await import(pathToFileURL(found).href)) as typeof exported;
68
- } catch {
69
- return null;
70
+ } catch (e) {
71
+ const said = (e as Error).message;
72
+ // The name itself is nowhere, as against code there that needs
73
+ // something that is not.
74
+ if ((e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' && said.includes(`'${found}'`)) return null;
75
+ const commonjs = e instanceof SyntaxError && /\b(export|import)\b/.test(said);
76
+ throw new Error(`${found} did not load: ${said}${commonjs ? '; it is an ES module read as CommonJS, so set "type": "module" in its package.json' : ''}`, { cause: e });
70
77
  }
71
78
  const { module, version, classes } = exported;
72
79
  if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes)) return null;