nervur 0.21.2 → 0.21.4

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.
@@ -46,7 +46,7 @@ export class Catalogue extends Faculty {
46
46
  const refused = loaded instanceof Error
47
47
  ? loaded.message
48
48
  : loaded === null
49
- ? `not found at ${row.from}`
49
+ ? (this.#harbor.missing(row.from) ?? `not found at ${row.from}`)
50
50
  : loaded.module !== module
51
51
  ? `${row.from} is module ${loaded.module}`
52
52
  : this.#harbor.check([...this.#running.values(), loaded]);
@@ -64,7 +64,7 @@ export class Catalogue extends Faculty {
64
64
  if (loaded instanceof Error)
65
65
  return { error: loaded.message };
66
66
  if (loaded === null)
67
- return { error: 'no such module' };
67
+ return { error: this.#harbor.missing(args.from) ?? 'no such module' };
68
68
  const running = new Map(this.#running);
69
69
  running.set(loaded.module, loaded);
70
70
  const refused = this.#harbor.run([...running.values()]);
@@ -7,6 +7,7 @@ export interface Hosting {
7
7
  route(pk: string, at: readonly string[] | null): boolean;
8
8
  keep(): Promise<boolean>;
9
9
  failures(): Failures;
10
+ readonly foreground: boolean;
10
11
  sweep(): Promise<string[]>;
11
12
  grounds(kind: string): boolean;
12
13
  awaken(key: string): Promise<void>;
@@ -54,11 +54,16 @@ export class Dock extends WardBeing {
54
54
  super(stance);
55
55
  this.#hosting = hosting;
56
56
  }
57
- // The census adds what did not stand, for whoever pilots.
57
+ // The census adds what did not stand, for whoever pilots, and whether
58
+ // the harbor lives only as long as a screen.
58
59
  describe(asker) {
59
60
  const blueprint = super.describe(asker);
60
- if (pilots(asker, this.notes(asker)))
61
- blueprint.notes.failed = this.#hosting.failures();
61
+ if (!pilots(asker, this.notes(asker)))
62
+ return blueprint;
63
+ const notes = blueprint.notes;
64
+ notes.failed = this.#hosting.failures();
65
+ if (this.#hosting.foreground)
66
+ notes.foreground = true;
62
67
  return blueprint;
63
68
  }
64
69
  get hosted() {
@@ -15,6 +15,7 @@ export declare class Harbor extends Carrier implements Hosting, Cataloguing, Res
15
15
  sweep(): Promise<string[]>;
16
16
  move(name: string, seed: string, from: string | undefined, to: string | undefined): Promise<string | null>;
17
17
  release(name: string, store: string | undefined): Promise<void>;
18
+ get foreground(): boolean;
18
19
  failures(): Failures;
19
20
  get box(): Ward;
20
21
  get dock(): Dock;
@@ -28,6 +29,7 @@ export declare class Harbor extends Carrier implements Hosting, Cataloguing, Res
28
29
  carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
29
30
  classOf(kind: string): BeingClass | undefined;
30
31
  load(from: string): Promise<Loaded | null>;
32
+ missing(from: string): string | null;
31
33
  check(modules: readonly Loaded[]): string | null;
32
34
  run(modules: readonly Loaded[]): string | null;
33
35
  stands(kinds: readonly string[]): boolean;
@@ -153,6 +153,9 @@ export class Harbor extends Carrier {
153
153
  return being instanceof Store ? [[key, being]] : [];
154
154
  });
155
155
  }
156
+ get foreground() {
157
+ return this.terrain.foreground;
158
+ }
156
159
  // Hosting: what did not stand when the harbor opened, each alone.
157
160
  failures() {
158
161
  return { wards: { ...this.#failed.wards }, routes: { ...this.#failed.routes } };
@@ -215,6 +218,9 @@ export class Harbor extends Carrier {
215
218
  load(from) {
216
219
  return this.terrain.loader.load(from);
217
220
  }
221
+ missing(from) {
222
+ return this.terrain.loader.missing(from);
223
+ }
218
224
  check(modules) {
219
225
  return Registry.refusal(this.#kit, modules);
220
226
  }
@@ -15,6 +15,7 @@ export declare abstract class Terrain {
15
15
  readonly faculties: readonly GroundFaculty[];
16
16
  readonly opens: readonly Opening[];
17
17
  readonly wakes: boolean;
18
+ get foreground(): boolean;
18
19
  claim(): Promise<void>;
19
20
  stand(_harbor: Harbor): Promise<void>;
20
21
  release(): Promise<void>;
@@ -6,6 +6,12 @@ export class Terrain {
6
6
  // Whether its carriers wake and listen where their cells say. A harbor
7
7
  // opened for one ask and closed again dials and hears nothing.
8
8
  wakes = true;
9
+ // Whether the harbor lives only as long as a screen: a tab's, or a worker
10
+ // one tab owns. It is reached only while that screen is open, and hears
11
+ // no push, so its census says so.
12
+ get foreground() {
13
+ return false;
14
+ }
9
15
  // Before the harbor opens: a terrain whose ground another run holds
10
16
  // refuses here. Once it stands: whatever the ground serves beside it.
11
17
  // Once its carriers sleep: what that served, gone.
@@ -18,6 +18,7 @@ export declare class HeldLoader extends Loader {
18
18
  #private;
19
19
  constructor(modules?: readonly Module[]);
20
20
  load(from: string): Promise<Loaded | null>;
21
+ missing(from: string): string;
21
22
  }
22
23
  export declare class VolatileMemory extends Memory {
23
24
  #private;
@@ -28,7 +28,9 @@ export class HeldCustody extends Custody {
28
28
  return Promise.resolve(this.#seed.slice());
29
29
  }
30
30
  }
31
- // Modules handed to this object, found by the name each declares.
31
+ // Modules handed to this object, found by the name each declares: the
32
+ // bundle a ground with no disk to load from carries, an edge's or a
33
+ // browser's. A path, a package or any other name is no module of it.
32
34
  export class HeldLoader extends Loader {
33
35
  #modules = new Map();
34
36
  constructor(modules = []) {
@@ -40,6 +42,10 @@ export class HeldLoader extends Loader {
40
42
  const m = this.#modules.get(from);
41
43
  return Promise.resolve(m ? { module: m.module, version: m.version, classes: m.classes, from } : null);
42
44
  }
45
+ missing(from) {
46
+ const carried = [...this.#modules.keys()];
47
+ return `${from} is no module this bundle carries${carried.length === 0 ? ', and it carries none' : `: it carries ${carried.join(', ')}`}`;
48
+ }
43
49
  }
44
50
  // Entries that live as long as this object, copied in and out, which is
45
51
  // what a restart in one process finds.
package/dist/proof/law.js CHANGED
@@ -194,7 +194,7 @@ export const lawScenes = (t, expect) => [
194
194
  expect.same(lost.names, ['w'], 'the rest of the harbor stands');
195
195
  expect.same((await census(lost)).beings.echo?.absent, true);
196
196
  expect.same(await lost.root('ask', { being: 'catalogue', method: 'modules' }), {
197
- answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `not found at ${law.module}` } },
197
+ answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `${law.module} is no module this bundle carries, and it carries none` } },
198
198
  });
199
199
  const again = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law]) });
200
200
  expect.same((await census(again)).beings.echo?.absent, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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",
@@ -175,6 +175,15 @@ export class BrowserTerrain extends PointerTerrain {
175
175
  this.where = where;
176
176
  }
177
177
 
178
+ // A shared or a service worker outlives any one tab, and a service
179
+ // worker is woken for a push with none open. Anywhere else, a page or a
180
+ // worker a tab made, the harbor lives with that tab.
181
+ override get foreground(): boolean {
182
+ const scope = globalThis as unknown as Record<string, unknown>;
183
+ const is = (name: string): boolean => typeof scope[name] === 'function' && globalThis instanceof (scope[name] as new () => unknown);
184
+ return !is('SharedWorkerGlobalScope') && !is('ServiceWorkerGlobalScope');
185
+ }
186
+
178
187
  override async claim(): Promise<void> {
179
188
  const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
180
189
  if (!locks) return;
@@ -19,14 +19,14 @@
19
19
  // --with-seed the seed goes too
20
20
  // nervur import <file> a carried harbor into an empty
21
21
  // folder, `-` being this input
22
- // nervur edge pack <file> the harbor for an edge: its seed
23
- // printed, for the worker's
24
- // NERVUR_SEED, and its sealed
25
- // package written to the file
26
- // nervur edge dev <file> [--port P] the harbor packed to the file and
22
+ // nervur edge pack <file> <name> the harbor for an edge, under its
23
+ // name, in the pack the file holds:
24
+ // its seed sealed under
25
+ // $NERVUR_SECRET, and its places
26
+ // nervur edge dev <file> <name> [--port P] the harbor packed so, and the pack
27
27
  // run by the worker's own wrangler
28
28
  // dev, from this folder, at
29
- // http://127.0.0.1:P/quo
29
+ // http://127.0.0.1:P/<name>/quo
30
30
  // nervur open <key> <kind> a faculty stands in the box ward
31
31
  // nervur host <ward> [--seed S] [--store K] a hosted ward stands, kept by the
32
32
  // root memory or the store K
@@ -197,11 +197,11 @@ export const main = async (argv: string[], env: Record<string, string | undefine
197
197
  return out({ landed: await land(dir, JSON.parse(text)) });
198
198
  }
199
199
  if (command === 'edge') {
200
- if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 2) throw new Error('edge takes pack or dev, and a file');
200
+ if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 3) throw new Error('edge takes pack or dev, a file, and the harbor name');
201
201
  if (flags.via !== undefined) throw new Error(`edge ${words[0]} reads this folder, so it takes no --via`);
202
202
  if (await isServed(dir)) throw new Error(`a harbor is served from ${dir}: stop it first`);
203
- if (words[0] === 'pack') return out(await pack(dir, words[1]!));
204
- const dev = await edgeDev(dir, words[1]!, process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port));
203
+ if (words[0] === 'pack') return out(await pack(dir, words[1]!, words[2]!, env.NERVUR_SECRET));
204
+ const dev = await edgeDev(dir, words[1]!, words[2]!, process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port), env.NERVUR_SECRET);
205
205
  const stop = (): void => void dev.close().then(() => process.exit(0));
206
206
  process.once('SIGINT', stop);
207
207
  process.once('SIGTERM', stop);
@@ -221,8 +221,9 @@ export const main = async (argv: string[], env: Record<string, string | undefine
221
221
  if (words.length !== 2 && words.length !== 3) throw new Error('pilot takes a name, an invitation, and an address where the invitation carries none');
222
222
  const [name, invitation, at] = words as [string, string, string | undefined];
223
223
  const held = JSON.parse(invitation) as JsonObject;
224
- const census = (await request(dir, {})) as { answer?: { beings?: JsonObject } };
225
- const web = census.answer?.beings !== undefined && Object.hasOwn(census.answer.beings, WEB_OPENING.key);
224
+ const census = (await request(dir, {})) as { answer?: { notes?: { beings?: JsonObject } } };
225
+ const beings = census.answer?.notes?.beings;
226
+ const web = beings !== undefined && Object.hasOwn(beings, WEB_OPENING.key);
226
227
  for (const req of pilotRequests(name, held, at, web)) {
227
228
  const answer = await request(dir, req);
228
229
  if (!failed(answer)) continue;
package/src/cli/edge.ts CHANGED
@@ -1,16 +1,22 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
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.
3
+ // Cloudflare. The harbor of a folder is packed under its name into the
4
+ // pack the worker imports, beside the harbors the pack holds already, and
5
+ // the worker's own `wrangler dev` runs them all, in the folder the command
6
+ // runs from, with the secret handed as `NERVUR_SECRET` through the
7
+ // environment so no listing of processes shows it. The secret is the one
8
+ // the command's environment names, or one drawn once and kept under
9
+ // `.nervur-edge`, beside the objects' storage, so a harbor landed once
10
+ // stands again across restarts, as it does on Cloudflare.
9
11
  //
10
12
  // wrangler is the worker's tool and never the kit's: the one who deploys
11
13
  // an edge has it already, and `edge dev` finds it where npm put it.
12
14
  import { spawn, type ChildProcess } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
17
  import { join } from 'node:path';
18
+ import { hex } from '../crypto/index.ts';
19
+ import { CryptoEntropy } from '../pointer/index.ts';
14
20
  import { pack } from './harbor.ts';
15
21
 
16
22
  export const EDGE_STATE = '.nervur-edge';
@@ -38,17 +44,30 @@ const ready = async (child: ChildProcess, url: string, said: () => string): Prom
38
44
  throw new Error(`wrangler dev never answered at ${url}: ${said()}`);
39
45
  };
40
46
 
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'], {
47
+ // The secret `edge dev` runs under: the one named, or the one kept here.
48
+ const devSecret = async (state: string, named: string | undefined): Promise<string> => {
49
+ if (named) return named;
50
+ const file = join(state, 'secret');
51
+ if (!existsSync(file)) {
52
+ await mkdir(state, { recursive: true, mode: 0o700 });
53
+ await writeFile(file, `${hex(new CryptoEntropy().draw(32))}\n`, { mode: 0o600, flag: 'wx' });
54
+ }
55
+ return (await readFile(file, 'utf8')).trim();
56
+ };
57
+
58
+ export const edgeDev = async (dir: string, file: string, name: string, cwd: string, port: number, named?: string): Promise<EdgeDev> => {
59
+ const state = join(cwd, EDGE_STATE);
60
+ const secret = await devSecret(state, named);
61
+ const { places } = (await pack(dir, file, name, secret)) as { places: number };
62
+ const child = spawn('npx', ['--no-install', 'wrangler', 'dev', '--ip', '127.0.0.1', '--port', String(port), '--persist-to', state, '--log-level', 'warn'], {
44
63
  cwd,
45
- env: { ...process.env, NERVUR_SEED: seed, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
64
+ env: { ...process.env, NERVUR_SECRET: secret, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
46
65
  stdio: ['ignore', 'ignore', 'pipe'],
47
66
  detached: true,
48
67
  });
49
68
  let err = '';
50
69
  child.stderr!.on('data', (chunk: Buffer) => (err = `${err}${chunk.toString('utf8')}`.slice(-4000)));
51
- const at = `http://127.0.0.1:${String(port)}/quo`;
70
+ const at = `http://127.0.0.1:${String(port)}/${name}/quo`;
52
71
  const close = (): Promise<void> =>
53
72
  new Promise((done) => {
54
73
  if (child.exitCode !== null) return done();
package/src/cli/harbor.ts CHANGED
@@ -10,7 +10,8 @@ import { existsSync } from 'node:fs';
10
10
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import type { JsonObject } from '../being/index.ts';
13
- import { hex } from '../crypto/index.ts';
13
+ import { hex, unhex } from '../crypto/index.ts';
14
+ import { isHarborName, readPack, sealSeed, type Pack } from '../edge/index.ts';
14
15
  import { FolderCustody, FolderMemory } from '../folder/index.ts';
15
16
  import { carryPlaces, Harbor, landPlaces, readCarried, rootLine, type CarrierFaculty, type Carried, type RootRequest } from '../harbor/index.ts';
16
17
  import { askServed, MODULES, NodeTerrain, socketOf } from '../node/index.ts';
@@ -49,13 +50,21 @@ export const carry = async (dir: string, seed: boolean): Promise<Carried> => {
49
50
  };
50
51
  };
51
52
 
52
- // What `nervur edge pack` hands a deploy: the seed, for the worker's
53
- // `NERVUR_SEED`, and the sealed package without it, for the bundle.
54
- export const pack = async (dir: string, file: string): Promise<JsonObject> => {
53
+ // What `nervur edge pack` hands a deploy: the harbor of a folder, under
54
+ // its name, in the pack the worker bundles, its seed sealed under the
55
+ // worker's `NERVUR_SECRET` beside its sealed places. Every other harbor
56
+ // the pack holds stays, so one worker carries them all, and nothing in the
57
+ // pack opens without the secret.
58
+ export const pack = async (dir: string, file: string, name: string, secret: string | undefined): Promise<JsonObject> => {
59
+ if (!isHarborName(name)) throw new Error(`${name} is no harbor name: lowercase letters, digits and -`);
60
+ if (!secret) throw new Error('edge pack seals the seed under NERVUR_SECRET, and none is set');
55
61
  if (!existsSync(join(dir, 'seed'))) throw new Error(`no harbor stands at ${dir}`);
56
62
  const { seed, places } = await carry(dir, true);
57
- await writeFile(file, `${JSON.stringify({ places })}\n`, { mode: 0o600 });
58
- return { seed: seed!, package: file, places: Object.keys(places).length };
63
+ const held = existsSync(file) ? readPack(JSON.parse(await readFile(file, 'utf8'))) : {};
64
+ if (held === null) throw new Error(`${file} is no pack`);
65
+ const packed: Pack = { ...held, [name]: { sealed: await sealSeed(secret, unhex(seed!)), places } };
66
+ await writeFile(file, `${JSON.stringify(packed)}\n`, { mode: 0o600 });
67
+ return { packed: name, package: file, harbors: Object.keys(packed).sort(), places: Object.keys(places).length };
59
68
  };
60
69
 
61
70
  // A carried harbor into an empty folder. A folder holding a seed or a
@@ -67,6 +67,11 @@ export type Loaded = Module & { readonly from: string };
67
67
  // one thing beside the seed a terrain hands in: a map, an import, a bundle.
68
68
  export abstract class Loader {
69
69
  abstract load(from: string): Promise<Loaded | null>;
70
+ // Why a name it found nothing under holds nothing, where it can say more
71
+ // than that: a bundle names that code comes from it alone.
72
+ missing(_from: string): string | null {
73
+ return null;
74
+ }
70
75
  }
71
76
 
72
77
  // Bytes to a ward pk in; bytes, or null for nothing, out. A harbor hands a