// SPDX-License-Identifier: Apache-2.0
// `nervur/node`: the Node ground. A harbor keeps its seed and sealed
// memory in a folder, its DNA among it, runs a module's source by
// importing it, hands its carriers Node's sockets and HTTP server, and
// serves the root line on a local
// socket, so the `nervur` command reaches it while it stands.
//
//
/seed the harbor's seed
// // its sealed memory
// /root.sock the root line while the harbor is served, open to
// this user alone, or in the temporary folder for a
// folder too deep for a socket path
//
// One run holds a folder: a terrain refuses a folder another run serves.
import { createHash } from 'node:crypto';
import { chmod, rm } from 'node:fs/promises';
import { connect, createServer, type Server } from 'node:net';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import type { JsonObject } from '../being/index.ts';
import { FolderCustody, FolderMemory } from '../folder/index.ts';
import type { Defaults, GroundProbe, Harbor, RootRequest } from '../harbor/index.ts';
import { webServe } from '../http/index.ts';
import { CryptoEntropy, PointerTerrain, SourceLoader } from '../pointer/index.ts';
import { NodeSockets } from '../tcp/index.ts';
export const NODE = 'node';
// Where a harbor of this user lives unless named: $NERVUR_DIR, then
// ~/.nervur.
export const harborDir = (env: Record = process.env): string => env.NERVUR_DIR ?? join(homedir(), '.nervur');
// A socket path has a hundred bytes or so on every system, so a folder too
// deep for one puts its socket in this user's temporary folder, under a
// name drawn from the folder's own path.
const SOCKET_PATH = 100;
export const socketOf = (dir: string): string => {
const beside = join(resolve(dir), 'root.sock');
if (Buffer.byteLength(beside) <= SOCKET_PATH) return beside;
return join(tmpdir(), `nervur-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 32)}.sock`);
};
// One root request over a served harbor's socket, or undefined where none
// is served.
export const askServed = (dir: string, request: RootRequest): Promise =>
new Promise((done, fail) => {
const socket = connect(socketOf(dir));
let text = '';
socket.once('connect', () => socket.write(`${JSON.stringify(request)}\n`));
socket.on('data', (chunk: Buffer) => {
text += chunk.toString('utf8');
const end = text.indexOf('\n');
if (end < 0) return;
socket.end();
done(JSON.parse(text.slice(0, end)) as JsonObject);
});
socket.once('error', (e: NodeJS.ErrnoException) => (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? done(undefined) : fail(e)));
});
export type NodeParts = {
readonly dir: string;
readonly defaults: Defaults;
// Whether its carriers wake, and whether it serves the root line.
readonly wakes?: boolean;
readonly serves?: boolean;
};
export class NodeTerrain extends PointerTerrain {
readonly dir: string;
readonly serves: boolean;
#server: Server | undefined;
constructor(parts: NodeParts) {
const entropy = new CryptoEntropy();
super({
entropy,
loader: new SourceLoader(),
custody: new FolderCustody(parts.dir, entropy),
memory: new FolderMemory(parts.dir),
tcp: () => new NodeSockets(),
web: webServe,
defaults: parts.defaults,
wakes: parts.wakes ?? true,
});
this.dir = parts.dir;
this.serves = parts.serves ?? true;
}
override async claim(): Promise {
if ((await askServed(this.dir, {})) !== undefined) throw new Error(`a harbor is already served from ${this.dir}`);
}
// The root line on the socket. A socket file nobody answers on is a
// stale one, and is replaced.
override async stand(harbor: Harbor): Promise {
if (!this.serves) return;
const root = socketOf(this.dir);
await rm(root, { force: true });
const server = createServer((socket) => {
let text = '';
socket.on('data', (chunk: Buffer) => {
text += chunk.toString('utf8');
for (let end = text.indexOf('\n'); end >= 0; end = text.indexOf('\n')) {
const line = text.slice(0, end);
text = text.slice(end + 1);
let value: unknown;
try {
value = JSON.parse(line);
} catch {
value = undefined;
}
void harbor
.ask(value)
.catch(() => ({ error: 'the harbor threw' }))
.then((answer) => socket.write(`${JSON.stringify(answer)}\n`));
}
});
socket.on('error', () => socket.destroy());
});
await new Promise((done) => server.listen(root, done));
await chmod(root, 0o600);
this.#server = server;
}
override async release(): Promise {
const server = this.#server;
if (!server) return;
this.#server = undefined;
await new Promise((done) => server.close(done));
await rm(socketOf(this.dir), { force: true });
}
}
// Node, and the engines that answer as Node does.
export const nodeGround: GroundProbe = {
name: NODE,
fits: () => typeof process !== 'undefined' && typeof process.versions?.node === 'string',
terrain: ({ where }, defaults) => new NodeTerrain({ dir: where ?? harborDir(), defaults }),
};