@dcasegr/skein 0.1.2 → 0.2.8

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.
@@ -4,6 +4,7 @@ import { CID } from "multiformats/cid";
4
4
  import type { Stamp } from "../runtime/syscalls.ts";
5
5
  import { Broadcaster, type ArcConfig } from "./arc.ts";
6
6
  import { Feeds, type FeedSpec } from "./feeds.ts";
7
+ import { TeranodeTracker } from "./teranode.ts";
7
8
  import { Cron } from "./cron.ts";
8
9
  import { type DispatchRow } from "../runtime/dispatch.ts";
9
10
  import { type BootSource, type Booted } from "./boot.ts";
@@ -11,7 +12,7 @@ import { ImageChain } from "./image-chain.ts";
11
12
  import { OverlaySync } from "./sync.ts";
12
13
  import { type AddressSeed, type DispatchSpec, type Genesis2Config, type Libp2pSpec } from "./genesis.ts";
13
14
  import { HANDLE, type HostDb, type InstanceRow } from "./instances.ts";
14
- import { type KernelSurface } from "./kernel.ts";
15
+ import { type HostChainTip, type KernelSurface } from "./kernel.ts";
15
16
  import { type Live } from "./liveness.ts";
16
17
  import { EventStreams } from "./events.ts";
17
18
  import { P2PHost, type Beacon, type InboundAnswer, type InboundCall, type P2PHostConfig, type Subscription } from "./p2p.ts";
@@ -53,6 +54,21 @@ export interface RouterOptions {
53
54
  };
54
55
  /** The host's headers feed (#102, SKEIN_HEADERS_URL): an SSE stream of block headers every enabled instance whose dispatch table takes events in box `chain` is subscribed to. */
55
56
  headersFeed?: string;
57
+ /**
58
+ * #156: the host's headers tracker over the Teranode P2P network (teranode.ts): its bootstrap
59
+ * peers (the network's, SKEIN_TERANODE_BOOTSTRAP), its first-sync hub (the network's default,
60
+ * SKEIN_HEADERS_HUB), its key file ($SKEIN_HOME/teranode.key). Beside the headers feed or alone:
61
+ * both feed the same intake. Absent (SKEIN_TERANODE=off, or a network with neither): no tracker.
62
+ */
63
+ teranode?: {
64
+ bootstrap: string[];
65
+ hub?: string;
66
+ peersFile?: string;
67
+ page?: number;
68
+ bulkPage?: number;
69
+ maxCrawl?: number;
70
+ discoveryMs?: number;
71
+ };
56
72
  /** The host's Arcade (#58, #65, arc.ts): where the broadcast events go, the status subscription, the `status` provider. Absent: no broadcaster (a broadcast event is dropped). */
57
73
  arc?: ArcConfig;
58
74
  /** Tests: how the broadcaster reaches Arcade. */
@@ -111,6 +127,11 @@ export interface RouterOptions {
111
127
  libp2p?: P2PHostConfig;
112
128
  /** An instance's libp2p peer key (signer.ts peerKey). Absent: no libp2p host; the kernels' `libp2p` is refused. */
113
129
  peerKeyFor?(handle: string): PrivateKey;
130
+ /**
131
+ * #156: the host node's peer key (P2PHost.hostNode: the Teranode tracker rides it; on a one-key
132
+ * host every instance's peer key, so they share it). Absent: a fresh key per start.
133
+ */
134
+ hostPeerKey?: PrivateKey;
114
135
  /**
115
136
  * A provider's key (#70, signer.ts providerKey: the HTTP proxy `fetch`, the
116
137
  * `waker`, `cron`, `libp2p`, `status`), which each new genesis's address
@@ -150,6 +171,17 @@ export interface RouterOptions {
150
171
  * stock wiring takes, from `handle`, handed over after its step's commit. Absent: logged as ignored.
151
172
  */
152
173
  appEvent?(handle: string, record: Record<string, unknown>): void;
174
+ /**
175
+ * An application host's reads (shruggr/open-exchange 0.11.0, the public read API): a GET for an
176
+ * instance (its hostname, `/@<handle>/`, or the root instance's hostname), asked AFTER the host's own
177
+ * reads there (the instance card, `/<app>/.live/<topic>`) and BEFORE the request is forwarded to the
178
+ * instance's program. `route` is the path at the instance (`/amm/pools`); `undefined`: not the host's,
179
+ * forwarded as before. An answer with `stream` is held open (RouterResponse). What the host needs
180
+ * for its reads is the Router's public surface: `dispatch` (in-process reads of the instances),
181
+ * `p2p.live.live(handle, topic)` (the beats kept), `events.subscribe(handle, query, send, close)` (an
182
+ * instance's emitted events), `o.db.list("enabled")` (the instances). Absent: nothing is asked.
183
+ */
184
+ appRead?(handle: string, route: string, url: URL, req: RouterRequest): Promise<RouterResponse | undefined>;
153
185
  }
154
186
  interface Loaded {
155
187
  row: InstanceRow;
@@ -180,6 +212,13 @@ export interface RouterResponse {
180
212
  status: number;
181
213
  headers: Record<string, string>;
182
214
  body: Uint8Array;
215
+ /**
216
+ * A response held open (an event stream an `appRead` answers): on a socket (Router.handler) the
217
+ * status and headers are written, then `stream` is called with `send` (writes a chunk) and `end`
218
+ * (closes the response); the function it returns is called once when the client goes. `body` is
219
+ * not written. In process (Router.dispatch's caller) it is not called: the caller gets `body`.
220
+ */
221
+ stream?(send: (chunk: string) => void, end: () => void): () => void;
183
222
  }
184
223
  export { HANDLE };
185
224
  /** Handles no instance may take on this host (H3), besides the router origin's own first label (Router.reserved): `id` (the router's own in production) and `host` (the host skein's). */
@@ -262,6 +301,13 @@ export declare class Router {
262
301
  private control?;
263
302
  /** The default image (#89), with its chain part (#132): what createInstance boots a new skein from. */
264
303
  readonly image: ImageChain;
304
+ /** #156: the headers tracker over the Teranode mesh (teranode.ts), when the host runs one. */
305
+ tracker?: TeranodeTracker;
306
+ /** #156: the libp2p the host node runs on: the instances' (`p2p`), else one for the tracker alone. */
307
+ hostP2p?: P2PHost;
308
+ private readonly p2pConfig;
309
+ /** #156: the header intake, one run at a time (the image chain, then the host chains). */
310
+ private intake;
265
311
  /** #130: each billed instance as last read (its host row's terms, the kernel's billing state), and what this host metered since its last tick. */
266
312
  private views;
267
313
  private periods;
@@ -392,6 +438,33 @@ export declare class Router {
392
438
  * libp2p arrivals, which do not queue here, need no queue.
393
439
  */
394
440
  serial<T>(handle: string, f: () => Promise<T>): Promise<T>;
441
+ /** #156: the key the host's headers arrive under (feeds.ts): the feed's URL, else the tracker's own. */
442
+ headersKey(): string | undefined;
443
+ /** #156: the Teranode tracker started: its node on the mesh, its first sync begun (`tracker.ready`). */
444
+ private startTracker;
445
+ /**
446
+ * #156: a run of headers from either source (the feed's, `at` undefined: image-chain.ts places
447
+ * each; the tracker's, at height `at`): into the image's chain part, then every host chain synced
448
+ * from it. One run at a time.
449
+ */
450
+ takeHeaders(raws: Uint8Array[], at?: number): Promise<void>;
451
+ /** #156: the host chains this host keeps once per database (its SQLite one, its RocksDB one, and those its rows name). */
452
+ private hostHolders;
453
+ /** #156: each host chain this host keeps (by database) and its tip (undefined: empty). */
454
+ hostChainTips(): Promise<Array<{
455
+ name: string;
456
+ tip: HostChainTip | undefined;
457
+ }>>;
458
+ /** #156: every host chain brought to the image chain's tip (from `from` when a run replaced from there). */
459
+ private syncHolders;
460
+ /**
461
+ * #156: a host chain brought to the image chain: an EMPTY one imports the image's chain part
462
+ * (`chain-import`, as an instance born with it would bring it: one frame) where its kernel can
463
+ * read it (the wasm kernel's SQLite database), else takes the image's headers from genesis in
464
+ * `headers` runs; one behind takes the headers it lacks; one the image does not hold at its
465
+ * height (a fork) from a little below it, or from `from`.
466
+ */
467
+ private syncHolder;
395
468
  /**
396
469
  * The network the host chain is on: SKEIN_NETWORK (`network`), else the genesis defaults'
397
470
  * `walletNetwork` (a test host's), else the kernel's default (main).
@@ -117,7 +117,8 @@ import { CID } from "multiformats/cid";
117
117
  import { rootIdentity } from "../runtime/identity.js";
118
118
  import { DEFAULTS, short, stampMs } from "../runtime/log.js";
119
119
  import { ARC_ROUTE, Broadcaster } from "./arc.js";
120
- import { DEFAULT_BOX, Feeds, feedsOf, txCid } from "./feeds.js";
120
+ import { BACKFILL_RUN, DEFAULT_BOX, Feeds, feedsOf, txCid } from "./feeds.js";
121
+ import { hashOf, TeranodeTracker } from "./teranode.js";
121
122
  import { Cron, dbSchedules } from "./cron.js";
122
123
  import { currentDispatch, takesEvent, takesMail } from "../runtime/dispatch.js";
123
124
  import { isSet, parseRocksSpec, parseSqliteSpec, storeSpec } from "../runtime/index-store.js";
@@ -136,9 +137,9 @@ import { keyBytes, keyHex } from "./genesis.js";
136
137
  import { HANDLE, pathOnly, TOKEN_HANDLE, topicsOf, validHandle } from "./instances.js";
137
138
  import { signClaim } from "../client/raw.js";
138
139
  import { DoorAnswered, Kernel, KernelHost } from "./kernel.js";
139
- import { foldLiveness, livenessEvent, livenessOf } from "./liveness.js";
140
+ import { foldLiveness, LIVENESS_MAX_MS, livenessEvent, livenessOf } from "./liveness.js";
140
141
  import { EventStreams, eventsQuery, sseMessage } from "./events.js";
141
- import { beaconEvent, beaconsOf, DEFAULT_LISTEN, emittedEvents, foldBeacons, foldSubscriptions, libp2pConfig, P2PHost, subscribedTopics, subscriptionEvent, subscriptionsOf } from "./p2p.js";
142
+ import { beaconEvent, beaconsOf, LOOPBACK_LISTEN, emittedEvents, foldBeacons, foldSubscriptions, libp2pConfig, P2PHost, subscribedTopics, subscriptionEvent, subscriptionsOf } from "./p2p.js";
142
143
  import { peerIdFromMultihash, peerIdFromString } from "@libp2p/peer-id";
143
144
  import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL, Providers, tooLarge } from "./providers.js";
144
145
  import { closeControl, listenControl } from "./control.js";
@@ -314,6 +315,13 @@ export class Router {
314
315
  control;
315
316
  /** The default image (#89), with its chain part (#132): what createInstance boots a new skein from. */
316
317
  image;
318
+ /** #156: the headers tracker over the Teranode mesh (teranode.ts), when the host runs one. */
319
+ tracker;
320
+ /** #156: the libp2p the host node runs on: the instances' (`p2p`), else one for the tracker alone. */
321
+ hostP2p;
322
+ p2pConfig;
323
+ /** #156: the header intake, one run at a time (the image chain, then the host chains). */
324
+ intake = Promise.resolve();
317
325
  /** #130: each billed instance as last read (its host row's terms, the kernel's billing state), and what this host metered since its last tick. */
318
326
  views = new Map();
319
327
  periods = new Map();
@@ -387,9 +395,12 @@ export class Router {
387
395
  holds: async (h, txid) => await (await this.hydrate(h)).kernel.hasBlock(txCid(txid)),
388
396
  });
389
397
  }
398
+ // #156: the host's libp2p (one node per key; the host node among them) — for the instances, and for the tracker on its own.
399
+ const hostConfig = { ...(o.libp2p ?? { listen: LOOPBACK_LISTEN, bootstrap: [], dht: "off", relays: [], mdns: false }), network: this.chainNetwork() ?? "main" };
400
+ this.p2pConfig = hostConfig;
390
401
  if (o.peerKeyFor) {
391
402
  this.p2p = new P2PHost({
392
- host: o.libp2p ?? { listen: DEFAULT_LISTEN, bootstrap: [], dht: "off", relays: [], mdns: false },
403
+ host: hostConfig, ...(o.hostPeerKey ? { hostKey: o.hostPeerKey } : {}),
393
404
  keyOf: o.peerKeyFor, discoveryMs: o.libp2pDiscoveryMs,
394
405
  inbound: (h, c) => this.p2pInbound(h, c),
395
406
  log: (s, l) => this.say(s, l),
@@ -397,8 +408,10 @@ export class Router {
397
408
  sign: (h, data) => this.signAs(h, data),
398
409
  identity: async (h) => { const id = this.o.db.get(h)?.identity; return id ? keyBytes(id) : undefined; },
399
410
  beat: (h, topic, b) => { this.sync?.beat(h, topic, b); void this.rememberPeer(h, topic, b.from); },
411
+ beats: o.db,
400
412
  });
401
413
  const p2pHost = this.p2p;
414
+ this.hostP2p = p2pHost;
402
415
  this.sync = new OverlaySync({
403
416
  overlayOf: (h, liveTopic) => this.loaded.get(h)?.beacons.find((b) => b.topic === liveTopic)?.app,
404
417
  local: (h, app, fn, query) => this.overlayRead(h, app, fn, query),
@@ -523,11 +536,28 @@ export class Router {
523
536
  }
524
537
  /** Hydrate every enabled row once (recovery at hydrate time: what a waiting thread awaits is handed to the providers again), then let them idle out. */
525
538
  async start() {
539
+ // #132, #156: the host's headers — the feed's and the Teranode tracker's — into one intake: the image's chain part, then the host chains.
540
+ const key = this.headersKey();
541
+ if (key)
542
+ this.feeds.listen(key, (raws, at) => this.takeHeaders(raws, at));
526
543
  // #132: the image's chain part, filled from the feed's history (from genesis once; then from near its tip), then grown by the feed.
527
544
  if (this.o.headersFeed) {
528
- void this.image.start();
529
- this.feeds.listen(this.o.headersFeed, (raws) => { void this.image.headers(raws); });
530
- }
545
+ const started = this.image.start();
546
+ void started.then(() => this.syncHolders()).catch((e) => this.say("router", `host chain: not filled from the image chain: ${e.message}`));
547
+ // With a feed, its history makes the first sync; the tracker follows the mesh once that is done.
548
+ if (this.o.teranode)
549
+ void started.then(() => this.startTracker()).catch((e) => this.say("router", `headers: the Teranode tracker not started: ${e.message}`));
550
+ }
551
+ else if (this.o.teranode) {
552
+ // #156: the tracker is the source: its first sync (a hub's, when the network has one) before any instance steps.
553
+ await this.startTracker().catch((e) => this.say("router", `headers: the Teranode tracker not started: ${e.message}`));
554
+ if (this.tracker?.o.hub)
555
+ await this.tracker.ready;
556
+ }
557
+ // #156: a host chain that is empty (or behind) filled from the image chain, before any instance is hydrated.
558
+ await this.syncHolders().catch((e) => this.say("router", `host chain: not filled from the image chain: ${e.message}`));
559
+ // #138: the stored liveness beats older than the longest window (a day) go before any instance reloads them.
560
+ this.o.db.pruneBeats(stampMs(this.now()) - LIVENESS_MAX_MS);
531
561
  for (const row of this.o.db.list("enabled")) {
532
562
  try {
533
563
  await this.hydrate(row.handle);
@@ -590,7 +620,8 @@ export class Router {
590
620
  this.control = undefined;
591
621
  this.providers.stop();
592
622
  this.events.stop();
593
- await Promise.all([this.cron.stop(), this.feeds.stop(), this.arc?.stop(), this.p2p?.stop()]);
623
+ await Promise.all([this.cron.stop(), this.feeds.stop(), this.arc?.stop(), this.tracker?.stop()]);
624
+ await Promise.all([this.p2p?.stop(), this.hostP2p !== this.p2p ? this.hostP2p?.stop() : undefined]); // #156: the host node with them
594
625
  // A kernel still hydrating: its load finishes (or fails) first, then it is stopped with the rest.
595
626
  await Promise.all([...this.loading.values()].map((p) => p.catch(() => { }))); // a failed load was reported to whoever asked for it
596
627
  await Promise.all([...this.loaded.values()].map((l) => l.kernel.stop()));
@@ -815,6 +846,128 @@ export class Router {
815
846
  return next;
816
847
  }
817
848
  // ---------------------------------------------------------------- the host chain (#156)
849
+ /** #156: the key the host's headers arrive under (feeds.ts): the feed's URL, else the tracker's own. */
850
+ headersKey() {
851
+ return this.o.headersFeed ?? (this.o.teranode ? `teranode:${this.chainNetwork() ?? "main"}` : undefined);
852
+ }
853
+ /** #156: the Teranode tracker started: its node on the mesh, its first sync begun (`tracker.ready`). */
854
+ async startTracker() {
855
+ const t = this.o.teranode;
856
+ if (!t || this.tracker || this.stopped)
857
+ return;
858
+ const key = this.headersKey();
859
+ // #156: no instance libp2p (no peer keys: a test's host): the host's libp2p for the tracker alone.
860
+ this.hostP2p ??= new P2PHost({
861
+ host: this.p2pConfig, ...(this.o.hostPeerKey ? { hostKey: this.o.hostPeerKey } : {}), discoveryMs: this.o.libp2pDiscoveryMs,
862
+ keyOf: () => { throw new Error("no instance libp2p on this host"); },
863
+ inbound: async () => ({ verdict: "ignore" }),
864
+ log: (s, l) => this.say(s, l),
865
+ });
866
+ this.tracker = new TeranodeTracker({
867
+ network: this.chainNetwork() ?? "main", ...t, p2p: this.hostP2p,
868
+ chain: {
869
+ tip: () => this.image.tip(),
870
+ find: (hash) => this.image.find(hash),
871
+ // A long run (a first sync, a deep catch-up): into the image and the host chains, then each running instance its chain event; a short one as the feed's headers go.
872
+ take: async (at, raws) => {
873
+ if (raws.length <= 4 * BACKFILL_RUN)
874
+ return await this.feeds.inject(key, raws, at);
875
+ await this.takeHeaders(raws, at);
876
+ for (const h of [...this.loaded.keys()])
877
+ void this.chainBirth(h).catch((e) => this.say(h, `host chain: the chain event not sent: ${e.message}`));
878
+ },
879
+ },
880
+ log: (l) => this.say("router", l),
881
+ });
882
+ this.image.ready = this.tracker.ready;
883
+ await this.tracker.start();
884
+ this.image.ready = this.tracker.ready;
885
+ }
886
+ /**
887
+ * #156: a run of headers from either source (the feed's, `at` undefined: image-chain.ts places
888
+ * each; the tracker's, at height `at`): into the image's chain part, then every host chain synced
889
+ * from it. One run at a time.
890
+ */
891
+ takeHeaders(raws, at) {
892
+ const p = this.intake.then(async () => {
893
+ if (at === undefined)
894
+ await this.image.headers(raws);
895
+ else
896
+ await this.image.put(at, raws);
897
+ await this.syncHolders(at);
898
+ });
899
+ this.intake = p.catch(() => { });
900
+ return p;
901
+ }
902
+ /** #156: the host chains this host keeps once per database (its SQLite one, its RocksDB one, and those its rows name). */
903
+ hostHolders() {
904
+ const sq = new Set(), rocks = new Set();
905
+ if (this.o.sqliteStore)
906
+ sq.add(this.o.sqliteStore);
907
+ if (this.o.rocksStore)
908
+ rocks.add(this.o.rocksStore);
909
+ for (const row of this.o.db.list("enabled")) {
910
+ const spec = storeSpec(row);
911
+ const r = parseRocksSpec(spec), q = parseSqliteSpec(spec);
912
+ if (r)
913
+ rocks.add(r.dir);
914
+ if (q)
915
+ sq.add(q.db);
916
+ }
917
+ return [
918
+ ...[...sq].map((db) => { const w = this.wasmHost(db); return { name: db, tip: () => w.chainTip(), headers: (raws) => w.chainHeaders(raws), importChain: w.importChain.bind(w) }; }),
919
+ ...[...rocks].map((dir) => { const k = this.kernelHost(dir); return { name: dir, tip: () => k.chainTip(), headers: (raws) => k.chainHeaders(raws) }; }),
920
+ ];
921
+ }
922
+ /** #156: each host chain this host keeps (by database) and its tip (undefined: empty). */
923
+ async hostChainTips() {
924
+ return await Promise.all(this.hostHolders().map(async (h) => ({ name: h.name, tip: await h.tip() })));
925
+ }
926
+ /** #156: every host chain brought to the image chain's tip (from `from` when a run replaced from there). */
927
+ async syncHolders(from) {
928
+ for (const h of this.hostHolders())
929
+ await this.syncHolder(h.name, h, from).catch((e) => this.say("router", `host chain ${h.name}: not synced from the image chain: ${e.message}`));
930
+ }
931
+ /**
932
+ * #156: a host chain brought to the image chain: an EMPTY one imports the image's chain part
933
+ * (`chain-import`, as an instance born with it would bring it: one frame) where its kernel can
934
+ * read it (the wasm kernel's SQLite database), else takes the image's headers from genesis in
935
+ * `headers` runs; one behind takes the headers it lacks; one the image does not hold at its
936
+ * height (a fork) from a little below it, or from `from`.
937
+ */
938
+ async syncHolder(name, h, from) {
939
+ const top = this.image.height();
940
+ if (top < 0)
941
+ return;
942
+ const tip = await h.tip();
943
+ const t0 = Date.now();
944
+ if (!tip && h.importChain) {
945
+ const part = this.image.chainPart();
946
+ if (part) {
947
+ const got = await h.importChain(part.chain, part.objects);
948
+ this.say("router", `host chain: ${got.added ?? 0} headers imported from the image chain (tip ${got.height}, ${Date.now() - t0} ms)`);
949
+ return;
950
+ }
951
+ }
952
+ let start = 0;
953
+ if (tip) {
954
+ const held = this.image.headerAt(tip.height);
955
+ start = held && hashOf(held) === tip.hash ? tip.height + 1 : Math.max(0, tip.height - 6);
956
+ if (from !== undefined)
957
+ start = Math.min(start, from);
958
+ }
959
+ if (start > top)
960
+ return;
961
+ let n = 0;
962
+ for (const run of this.image.runs(start, BACKFILL_RUN)) {
963
+ await h.headers(run);
964
+ n += run.length;
965
+ if (top - start > 10 * BACKFILL_RUN && n % (50 * BACKFILL_RUN) < BACKFILL_RUN)
966
+ this.say("router", `host chain: ${start + n}/${top + 1} from the image chain`);
967
+ }
968
+ if (n > BACKFILL_RUN)
969
+ this.say("router", `host chain: ${n} headers from the image chain (to ${top}, ${Date.now() - t0} ms)`);
970
+ }
818
971
  /**
819
972
  * The network the host chain is on: SKEIN_NETWORK (`network`), else the genesis defaults'
820
973
  * `walletNetwork` (a test host's), else the kernel's default (main).
@@ -955,6 +1108,9 @@ export class Router {
955
1108
  await kernel.start();
956
1109
  // #156: after the start (the genesis processed: its tree is the head `main`), before any event is admitted.
957
1110
  await this.chainBootstrap(handle, kernel);
1111
+ // #156: a serve kernel's own host chain still empty (born before the image held a chain): from the image chain now.
1112
+ if (kernel instanceof Kernel)
1113
+ await this.syncHolder(handle, { tip: () => kernel.chainTip(), headers: (raws) => kernel.chainHeaders(raws) }).catch((e) => this.say(handle, `host chain: not synced from the image chain: ${e.message}`));
958
1114
  await kernel.running(identity);
959
1115
  void kernel.idle().catch(() => { }); // busy until what start resumed is done; a kernel that exits meanwhile is reported by its exit
960
1116
  if (!row.identity)
@@ -1188,7 +1344,14 @@ export class Router {
1188
1344
  // operator's key, `appConfig` merged.
1189
1345
  // #162: any instance's app config at birth (a create's `config`), the host skein's owner at birth.
1190
1346
  // #164: an owned instance's genesis names `owner` root (boot.ts then seeds no claim route), as the host skein's does.
1191
- await this.bootRow(handle, await this.namedImage(o.image, o.host), { image: true, manager: o.host, ...(owned ? { owner } : {}), ...(o.appConfig ? { appConfig: o.appConfig } : {}) });
1347
+ // The image read from its root to the last object walked: a header taken meanwhile keeps the objects it replaces.
1348
+ const release = this.image.hold();
1349
+ try {
1350
+ await this.bootRow(handle, await this.namedImage(o.image, o.host), { image: true, manager: o.host, ...(owned ? { owner } : {}), ...(o.appConfig ? { appConfig: o.appConfig } : {}) });
1351
+ }
1352
+ finally {
1353
+ release();
1354
+ }
1192
1355
  const l = await this.hydrate(handle);
1193
1356
  if (claim) {
1194
1357
  // #127: the owner's own message, forwarded as signed; the host signs nothing for the owner. #143: one the door
@@ -1399,7 +1562,7 @@ export class Router {
1399
1562
  * handled and unhandled) or stopped. A failure is logged, not fatal.
1400
1563
  */
1401
1564
  async syncDispatch(l, first = false) {
1402
- if ((!this.p2p && !this.o.headersFeed && !this.events.open(l.row.handle)) || l.kernel.gone)
1565
+ if ((!this.p2p && !this.headersKey() && !this.events.open(l.row.handle)) || l.kernel.gone)
1403
1566
  return;
1404
1567
  const d = await l.kernel.dispatch().catch((e) => { this.say(l.row.handle, `the dispatch table not read: ${e.message}`); return undefined; });
1405
1568
  if (d === undefined)
@@ -1720,7 +1883,7 @@ export class Router {
1720
1883
  * else not. One line when that changes.
1721
1884
  */
1722
1885
  followHeaders(handle, rows) {
1723
- const url = this.o.headersFeed;
1886
+ const url = this.headersKey();
1724
1887
  if (!url)
1725
1888
  return;
1726
1889
  const want = this.o.db.get(handle)?.status === "enabled" && rows.some((r) => r.address === DEFAULT_BOX && takesEvent(r, DEFAULT_BOX));
@@ -1749,7 +1912,7 @@ export class Router {
1749
1912
  }
1750
1913
  /** Follow the host's headers feed for an instance whose status changed (#102): its table read again. */
1751
1914
  async refollowHeaders(handle) {
1752
- if (!this.o.headersFeed)
1915
+ if (!this.headersKey())
1753
1916
  return;
1754
1917
  const l = this.loaded.get(handle);
1755
1918
  const d = l && !l.kernel.gone ? await l.kernel.dispatch().catch((e) => { this.say(handle, `the dispatch table not read: ${e.message}`); return undefined; }) : undefined;
@@ -2072,6 +2235,12 @@ export class Router {
2072
2235
  // #148: a stream, served on a socket only (handler).
2073
2236
  if (req.method === "GET" && EVENTS_ROUTE.test(x.route))
2074
2237
  return json(400, { status: "error", code: "ERR_BAD_REQUEST", description: "an event stream (text/event-stream) is served on a connection of its own, not in process" });
2238
+ // An application host's own reads (appRead): before the instance's program; undefined falls through.
2239
+ if (req.method === "GET" && this.o.appRead) {
2240
+ const a = await this.o.appRead(x.handle, x.route, url, req);
2241
+ if (a)
2242
+ return a;
2243
+ }
2075
2244
  return await this.held(this.forward(x.handle, x.route, url, req));
2076
2245
  };
2077
2246
  if (t)
@@ -2079,6 +2248,16 @@ export class Router {
2079
2248
  const path = url.pathname;
2080
2249
  if (req.method === "GET" && path === "/skeins")
2081
2250
  return await this.skeinsHeldBy(url.searchParams.get("key") ?? "");
2251
+ if (path === `${ARC_ROUTE}/callback`)
2252
+ return await this.arcRequest(req, path);
2253
+ // SKEIN_ROOT_INSTANCE: a hostname that is no instance's serves the root instance (the standalone's one app at
2254
+ // localhost:<port>/) — every path but the router's own three above (.well-known/skein-host, /skeins, the ARC
2255
+ // callback). The BRC-169 set below is the host skein's only when no root instance takes the hostname: with one,
2256
+ // POST /.well-known/auth must shake hands with the root instance (its sessions are its own, #68) and
2257
+ // GET /manifest.json is the app's (its grouped permissions), as at an instance's own hostname.
2258
+ const root = this.o.rootInstance;
2259
+ if (root && this.o.db.get(root)?.status === "enabled")
2260
+ return await serve({ handle: root, route: path });
2082
2261
  // #113: the host's own origin's BRC-169 requests are the host skein's (its onboarding app's routes).
2083
2262
  const route = DISCOVERY[`${req.method} ${path}`] ?? (req.method === "GET" && path.startsWith(PAYMAIL_PREFIX) ? `/${ONBOARD_APP}${path}` : undefined);
2084
2263
  if (route) {
@@ -2090,13 +2269,6 @@ export class Router {
2090
2269
  return f;
2091
2270
  return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "this host has no host skein (skein-host init): no BRC-169 here" });
2092
2271
  }
2093
- if (path === `${ARC_ROUTE}/callback`)
2094
- return await this.arcRequest(req, path);
2095
- // SKEIN_ROOT_INSTANCE: a hostname that is no instance's serves the root instance (the standalone's one app at localhost:<port>/),
2096
- // after the host's own paths above.
2097
- const root = this.o.rootInstance;
2098
- if (root && this.o.db.get(root)?.status === "enabled")
2099
- return await serve({ handle: root, route: path });
2100
2272
  return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "no instance here: an instance is at http://<handle>.localhost:<port>/ or /@<handle>/" });
2101
2273
  }
2102
2274
  /**
@@ -2322,7 +2494,28 @@ export class Router {
2322
2494
  req.on("end", () => {
2323
2495
  const url = `http://${req.headers.host ?? `127.0.0.1:${this.port}`}${req.url ?? "/"}`;
2324
2496
  this.dispatch({ method: req.method ?? "GET", url, headers: headerMap(req.headers), body: new Uint8Array(Buffer.concat(chunks)) })
2325
- .then((r) => { res.writeHead(r.status, { ...cors, ...r.headers, "content-length": String(r.body.length) }); res.end(r.body); })
2497
+ .then((r) => {
2498
+ if (!r.stream) {
2499
+ res.writeHead(r.status, { ...cors, ...r.headers, "content-length": String(r.body.length) });
2500
+ res.end(r.body);
2501
+ return;
2502
+ }
2503
+ // A response held open (an appRead's event stream): written as it comes, until either side ends it.
2504
+ if (res.destroyed)
2505
+ return;
2506
+ res.writeHead(r.status, { ...cors, ...r.headers });
2507
+ res.flushHeaders();
2508
+ let forget;
2509
+ let gone = false;
2510
+ const leave = () => { if (gone)
2511
+ return; gone = true; forget?.(); };
2512
+ res.on("close", leave);
2513
+ forget = r.stream((chunk) => { if (!gone)
2514
+ res.write(chunk); }, () => { if (!gone)
2515
+ res.end(); });
2516
+ if (gone)
2517
+ forget();
2518
+ })
2326
2519
  .catch((e) => { if (!res.headersSent) {
2327
2520
  res.writeHead(500, { ...cors, "content-type": "application/json" });
2328
2521
  res.end(JSON.stringify({ status: "error", code: "ERR_INTERNAL", description: e.message }));
@@ -32,6 +32,12 @@ export declare class Signer {
32
32
  * (p2p.ts peerIdOf).
33
33
  */
34
34
  peerKey(id: string): PrivateKey;
35
+ /**
36
+ * The host node's peer key (#156): the key's child [2, "skein instance"], key ID `libp2p`, self —
37
+ * in one-key mode every instance's peer key (peerKey), so they share the host node; in master-key
38
+ * mode the same rule over the master (its own node beside the instances').
39
+ */
40
+ hostPeerKey(): PrivateKey;
35
41
  /** The instance's identity key (hex): what `skein-host add` records. */
36
42
  identity(id: string): string;
37
43
  /** The instance's signer: a ProtoWallet over its root key (a WalletInterface whose actions reject). */
@@ -137,6 +137,14 @@ export class Signer {
137
137
  return this.deriver.derivePrivateKey(INSTANCE_PROTOCOL, "libp2p", "self");
138
138
  return new KeyDeriver(this.instanceKey(id)).derivePrivateKey(INSTANCE_PROTOCOL, `libp2p:${id}`, "self");
139
139
  }
140
+ /**
141
+ * The host node's peer key (#156): the key's child [2, "skein instance"], key ID `libp2p`, self —
142
+ * in one-key mode every instance's peer key (peerKey), so they share the host node; in master-key
143
+ * mode the same rule over the master (its own node beside the instances').
144
+ */
145
+ hostPeerKey() {
146
+ return this.deriver.derivePrivateKey(INSTANCE_PROTOCOL, "libp2p", "self");
147
+ }
140
148
  /** The instance's identity key (hex): what `skein-host add` records. */
141
149
  identity(id) {
142
150
  return this.instanceKey(id).toPublicKey().toString();
@@ -0,0 +1,132 @@
1
+ import type { Libp2p } from "@libp2p/interface";
2
+ import { type P2PHost } from "./p2p.ts";
3
+ /** The network's bootstrap peers (go-teranode-p2p-client config.go); regtest has none. */
4
+ export declare const TERANODE_BOOTSTRAP: Record<string, string[]>;
5
+ /** The default data hub per network (first sync; the gorillanode Teranode asset API); regtest has none. */
6
+ export declare const DEFAULT_HUBS: Record<string, string>;
7
+ /** The block topic of a network (SKEIN_NETWORK's names, or the topic's own). */
8
+ export declare function blockTopic(network: string): string;
9
+ /** A header's hash, display hex. */
10
+ export declare const hashOf: (raw: Uint8Array) => string;
11
+ /** A header's parent's hash, display hex. */
12
+ export declare const prevOf: (raw: Uint8Array) => string;
13
+ /** One block announcement, as the tracker reads it. */
14
+ export interface Announcement {
15
+ hash: string;
16
+ height: number;
17
+ header: Uint8Array;
18
+ hub?: string;
19
+ peer?: string;
20
+ }
21
+ /**
22
+ * A block announcement out of a message on the block topic: the bus's envelope or a bare
23
+ * BlockMessage. Undefined (dropped) when it is neither, its header is not 80 bytes of hex, or the
24
+ * header's hash is not the `Hash` it names.
25
+ */
26
+ export declare function parseAnnouncement(data: Uint8Array): Announcement | undefined;
27
+ export interface HubOptions {
28
+ fetch?: typeof fetch;
29
+ timeoutMs?: number;
30
+ }
31
+ /** The hub's best header (80 bytes) and, when it says, its height (`bestblockheader/json`). */
32
+ export declare function hubBest(hub: string, o?: HubOptions): Promise<{
33
+ header: Uint8Array;
34
+ hash: string;
35
+ height?: number;
36
+ }>;
37
+ /**
38
+ * Up to `n` headers from `hash` back (child to parent, `hash` first): each one's proof of work and
39
+ * every link checked; a page that does not start at `hash` or does not link is refused.
40
+ */
41
+ export declare function hubBack(hub: string, hash: string, n: number, o?: HubOptions): Promise<Uint8Array[]>;
42
+ /** What the tracker reads and writes: the host's image chain and the intake behind it (Router). */
43
+ export interface TrackerChain {
44
+ /** The tip held (undefined: nothing held). */
45
+ tip(): {
46
+ height: number;
47
+ hash: string;
48
+ } | undefined;
49
+ /** The height of a header held near the tip, by hash (display hex). */
50
+ find(hash: string): number | undefined;
51
+ /** A run of headers, parents first, at `height` (its first one's): into the image chain and on to the host chain and the instances. */
52
+ take(height: number, raws: Uint8Array[]): Promise<void>;
53
+ }
54
+ export interface TrackerOptions {
55
+ network: string;
56
+ /** Bootstrap multiaddrs (dnsaddr resolved by libp2p): the network's and SKEIN_TERANODE_BOOTSTRAP's; dialled while the host node has few peers. */
57
+ bootstrap: string[];
58
+ /** The host's libp2p (#156): the tracker rides its host node. None: no mesh (a hub-only first sync). */
59
+ p2p?: P2PHost;
60
+ /** The hub for the first sync (DEFAULT_HUBS, SKEIN_HEADERS_HUB); none: the first announcement's. */
61
+ hub?: string;
62
+ /**
63
+ * Where the peers met are kept between runs (go-p2p-message-bus's peer cache): dialled at start
64
+ * beside the bootstrap peers, so a restart does not hang on the bootstrap; none: not kept.
65
+ */
66
+ peersFile?: string;
67
+ chain: TrackerChain;
68
+ /** Headers per hub request: a gap's crawl (default 2 000) and the first sync's (default 10 000; a hub may answer fewer). */
69
+ page?: number;
70
+ bulkPage?: number;
71
+ /** The most headers a gap's crawl walks back before it gives up (default 20 160). */
72
+ maxCrawl?: number;
73
+ /** Peer upkeep: bootstrap redialled while too few are on the block topic (ms, default 30 000). */
74
+ discoveryMs?: number;
75
+ hub_?: HubOptions;
76
+ log?(line: string): void;
77
+ }
78
+ export declare class TeranodeTracker {
79
+ readonly o: TrackerOptions;
80
+ readonly topic: string;
81
+ node?: Libp2p;
82
+ /** Settles when the first sync is done (or there was nothing to do); rejects never. */
83
+ ready: Promise<void>;
84
+ private queue;
85
+ private stopped;
86
+ private timer?;
87
+ private syncing;
88
+ /** The newest announcement seen while the first sync ran (caught up from after it). */
89
+ private pending?;
90
+ private peers;
91
+ private lastBootErr;
92
+ /** Counts for the logs and tests. */
93
+ readonly stats: {
94
+ announcements: number;
95
+ dropped: number;
96
+ taken: number;
97
+ crawled: number;
98
+ known: number;
99
+ };
100
+ constructor(o: TrackerOptions);
101
+ private say;
102
+ /** Join the mesh (on the host node) and start the first sync when the image chain is empty. */
103
+ start(): Promise<void>;
104
+ stop(): Promise<void>;
105
+ /** The block topic on the host node: each message judged here (an announcement Accept and followed, anything else Reject); upkeep every round. */
106
+ private join;
107
+ /** The bootstrap peers dialled while too few are on the block topic. */
108
+ private upkeep;
109
+ /** The host node's DHT routing table's size, the skein DHT's (undefined: the DHT off). */
110
+ tables(): {
111
+ skein?: number;
112
+ };
113
+ /** The peers on the block topic the host node knows (GossipSub's view). */
114
+ onTopic(): number;
115
+ /** The peers kept from earlier runs (their dialable addresses). */
116
+ private cached;
117
+ /** The peers connected now — the block topic's first — kept for the next run (at most 32). */
118
+ private keep;
119
+ /** An announcement: in turn with the others (one change to the chain at a time). */
120
+ announce(a: Announcement): void;
121
+ /** go-chaintracks processBlockMessage: known, a child of a header near the tip, or a crawl back from its hub. */
122
+ private follow;
123
+ /**
124
+ * go-chaintracks SyncFromRemoteTip: back from `hash` through the hub until a header held (its
125
+ * height `base`), or the genesis header (base -1: the run starts at 0); the run parents first.
126
+ */
127
+ private crawl;
128
+ /** The first sync: an empty chain filled from a hub (the default, else the first announcement's). */
129
+ private initial;
130
+ /** The whole chain, from `best` back to genesis through `hub`, written in height order. */
131
+ private bulk;
132
+ }