@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.
- package/README.md +37 -0
- package/dist/host/arc.d.ts +12 -2
- package/dist/host/arc.js +41 -13
- package/dist/host/cli.d.ts +5 -0
- package/dist/host/cli.js +37 -4
- package/dist/host/fake-teranode.d.ts +29 -0
- package/dist/host/fake-teranode.js +100 -0
- package/dist/host/feeds.d.ts +9 -1
- package/dist/host/feeds.js +30 -5
- package/dist/host/image-chain.d.ts +30 -0
- package/dist/host/image-chain.js +84 -2
- package/dist/host/instances.d.ts +19 -0
- package/dist/host/instances.js +38 -1
- package/dist/host/liveness.d.ts +22 -2
- package/dist/host/liveness.js +42 -5
- package/dist/host/p2p.d.ts +107 -8
- package/dist/host/p2p.js +298 -53
- package/dist/host/router.d.ts +74 -1
- package/dist/host/router.js +213 -20
- package/dist/host/signer.d.ts +6 -0
- package/dist/host/signer.js +8 -0
- package/dist/host/teranode.d.ts +132 -0
- package/dist/host/teranode.js +406 -0
- package/dist/host/testhost.d.ts +5 -0
- package/dist/host/testhost.js +8 -3
- package/dist/host/wasm-kernel.d.ts +9 -0
- package/dist/host/wasm-kernel.js +46 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -314,6 +314,43 @@ bin/skein-host run # the first run: operator.key, master.key, host.db and
|
|
|
314
314
|
default image with the whole header chain from that chaintracks' history
|
|
315
315
|
at start and appends every header the feed brings, so a new skein is born
|
|
316
316
|
with the chain (#132, docs/BOOTSTRAP.md, "The image's chain part").
|
|
317
|
+
With no setting at all (#156, `src/host/teranode.ts`), `skein-host run`
|
|
318
|
+
follows the network's Teranode P2P mesh for block announcements (the
|
|
319
|
+
block topic `teranode/bitcoin/1.0.0/<network>-block`, bootstrap
|
|
320
|
+
`/dnsaddr/<network>.bootstrap.teranode.bsvb.tech`), crawls a gap back from
|
|
321
|
+
the announcer's data hub, and on an empty image chain syncs the whole
|
|
322
|
+
chain first from the network's default hub (gorillanode;
|
|
323
|
+
`SKEIN_HEADERS_HUB`); an empty host chain is then filled from the image
|
|
324
|
+
chain. Beside `SKEIN_HEADERS_URL` both feed the same intake;
|
|
325
|
+
`SKEIN_TERANODE=off` turns it off. The tracker rides the host's ONE
|
|
326
|
+
libp2p node (the host key's; a one-key host's instances are on it too),
|
|
327
|
+
which carries ONE Kademlia DHT, the skein DHT
|
|
328
|
+
(`/skein/kad/<network>/1.0.0`, the overlay's topics). It does not join
|
|
329
|
+
Teranode's DHT: that is the public IPFS DHT, and even a kad client crawls
|
|
330
|
+
it and fills a js-libp2p node's connection cap, which then refuses every
|
|
331
|
+
newcomer. Teranode peers come from the bootstrap list and GossipSub:
|
|
332
|
+
`SKEIN_TERANODE_BOOTSTRAP` adds peers beside the network's defaults
|
|
333
|
+
(gorillanode in DHT-server mode, the bsvb seeds, a hosted skein node are
|
|
334
|
+
all fine entries; any one that accepts is enough), redialled while fewer
|
|
335
|
+
than 4 are on the block topic.
|
|
336
|
+
- **Per-network defaults** (`SKEIN_NETWORK`, default `main`): a host needs
|
|
337
|
+
no setting to find the overlay or broadcast. Unset means the network's;
|
|
338
|
+
`off` turns the default off.
|
|
339
|
+
|
|
340
|
+
| setting | unset = the network's | |
|
|
341
|
+
|---|---|---|
|
|
342
|
+
| `SKEIN_LIBP2P_BOOTSTRAP` | test: `/dns4/p2p.testnet.openex.dev/tcp/7534/p2p/16Uiu2HAmVN7Cc7TrFGVrbdoV1mMGBF8LQBvPZ5trzgPArfagBRSB` (the hosted Open Exchange node); main, regtest: none yet | skein peers named here are dialled beside the network's (unioned); `off`: none at all. A node whose own peer id is a default skips it |
|
|
343
|
+
| `SKEIN_ARC_URL` | test: `https://testnet.arcade.1sat.app`; main: `https://arcade.1sat.app`; regtest: none | `off`: no Arcade (a broadcast event is dropped). With the network's Arcade and no `SKEIN_ARC_TOKEN`, the host makes its callback token once (32 random bytes, hex) and appends `SKEIN_ARC_TOKEN=…` to `$SKEIN_HOME/host.env`, read from there after, as the key file is. A URL named here needs its token named too |
|
|
344
|
+
- The defaults are tuned for discovery: the node listens on TCP port
|
|
345
|
+
**7534** on every interface (IPv4 and IPv6; another port if 7534 is
|
|
346
|
+
taken), asks the router to open it by UPnP/NAT-PMP where allowed
|
|
347
|
+
(`SKEIN_LIBP2P_UPNP=off` stops that), and runs the skein DHT in `auto`
|
|
348
|
+
mode — a client until AutoNAT confirms a public address, then a server
|
|
349
|
+
(`SKEIN_LIBP2P_DHT` sets its mode).
|
|
350
|
+
Nobody has to open a firewall; to be a bootstrap for others, open
|
|
351
|
+
TCP 7534 (or forward it on the router) and the node becomes a DHT
|
|
352
|
+
server by itself. A hosted node needs nothing set; `SKEIN_LIBP2P_DHT=server`
|
|
353
|
+
makes it a server from the start instead of waiting for AutoNAT.
|
|
317
354
|
|
|
318
355
|
## Talk to it
|
|
319
356
|
|
package/dist/host/arc.d.ts
CHANGED
|
@@ -13,8 +13,18 @@ export interface ArcConfig {
|
|
|
13
13
|
}
|
|
14
14
|
/** The webhook's path on the router. */
|
|
15
15
|
export declare const ARC_ROUTE = "/arc";
|
|
16
|
-
/** The
|
|
17
|
-
export declare
|
|
16
|
+
/** The network's Arcade (SKEIN_NETWORK's names) when SKEIN_ARC_URL is unset; regtest has none. */
|
|
17
|
+
export declare const DEFAULT_ARC: Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the
|
|
20
|
+
* environment, else $SKEIN_HOME/host.env). SKEIN_ARC_URL unset: the network's (DEFAULT_ARC, by SKEIN_NETWORK,
|
|
21
|
+
* default main); `off`: none, as on regtest. With an Arcade and no SKEIN_ARC_TOKEN, the host's token is made
|
|
22
|
+
* (32 random bytes, hex) and appended to $SKEIN_HOME/host.env (the file made if absent), then read from it after —
|
|
23
|
+
* as the key file is; `made` reports it. Without a home there is nowhere to keep one: an error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function hostArcConfig(vars: Record<string, string | undefined>, home?: string, o?: {
|
|
26
|
+
made?: (file: string) => void;
|
|
27
|
+
}): ArcConfig | undefined;
|
|
18
28
|
export interface ArcAnswer {
|
|
19
29
|
status: number;
|
|
20
30
|
headers: Record<string, string>;
|
package/dist/host/arc.js
CHANGED
|
@@ -52,30 +52,58 @@
|
|
|
52
52
|
// Arcade itself may later sign its statuses (a signed-message callback beside
|
|
53
53
|
// webhook and SSE): it would then be the status provider an instance
|
|
54
54
|
// subscribes to, and nothing on the instance's side changes.
|
|
55
|
-
import {
|
|
55
|
+
import { randomBytes } from "node:crypto";
|
|
56
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
56
57
|
import { join } from "node:path";
|
|
57
58
|
import { Transaction } from "@bsv/sdk";
|
|
58
59
|
import { DEFAULT_BOX, SseStream, statusOf, txCid } from "./feeds.js";
|
|
59
60
|
/** The webhook's path on the router. */
|
|
60
61
|
export const ARC_ROUTE = "/arc";
|
|
61
|
-
/** The
|
|
62
|
-
export
|
|
63
|
-
|
|
62
|
+
/** The network's Arcade (SKEIN_NETWORK's names) when SKEIN_ARC_URL is unset; regtest has none. */
|
|
63
|
+
export const DEFAULT_ARC = {
|
|
64
|
+
main: "https://arcade.1sat.app",
|
|
65
|
+
test: "https://testnet.arcade.1sat.app",
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the
|
|
69
|
+
* environment, else $SKEIN_HOME/host.env). SKEIN_ARC_URL unset: the network's (DEFAULT_ARC, by SKEIN_NETWORK,
|
|
70
|
+
* default main); `off`: none, as on regtest. With an Arcade and no SKEIN_ARC_TOKEN, the host's token is made
|
|
71
|
+
* (32 random bytes, hex) and appended to $SKEIN_HOME/host.env (the file made if absent), then read from it after —
|
|
72
|
+
* as the key file is; `made` reports it. Without a home there is nowhere to keep one: an error.
|
|
73
|
+
*/
|
|
74
|
+
export function hostArcConfig(vars, home, o = {}) {
|
|
64
75
|
const envFile = home ? join(home, "host.env") : undefined;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
const readFile = () => {
|
|
77
|
+
const file = {};
|
|
78
|
+
if (envFile && existsSync(envFile)) {
|
|
79
|
+
for (const line of readFileSync(envFile, "utf8").split("\n")) {
|
|
80
|
+
const m = /^\s*(?:export\s+)?(SKEIN_ARC_[A-Z_]+|SKEIN_NETWORK)=(.*)$/.exec(line);
|
|
81
|
+
if (m)
|
|
82
|
+
file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
83
|
+
}
|
|
70
84
|
}
|
|
71
|
-
|
|
85
|
+
return file;
|
|
86
|
+
};
|
|
87
|
+
const file = readFile();
|
|
72
88
|
const get = (k) => vars[k] || file[k] || undefined;
|
|
73
|
-
const
|
|
89
|
+
const set = get("SKEIN_ARC_URL");
|
|
90
|
+
if (set?.toLowerCase() === "off")
|
|
91
|
+
return undefined;
|
|
92
|
+
const url = set ?? DEFAULT_ARC[get("SKEIN_NETWORK") || "main"];
|
|
74
93
|
if (!url)
|
|
75
94
|
return undefined;
|
|
76
|
-
|
|
77
|
-
if (!token)
|
|
95
|
+
let token = get("SKEIN_ARC_TOKEN");
|
|
96
|
+
if (!token && set)
|
|
78
97
|
throw new Error("SKEIN_ARC_URL is set but SKEIN_ARC_TOKEN is not: the host's callback token scopes its status stream");
|
|
98
|
+
if (!token) {
|
|
99
|
+
if (!envFile)
|
|
100
|
+
throw new Error(`the network's Arcade (${url}) needs the host's callback token (SKEIN_ARC_TOKEN), and there is no SKEIN_HOME to keep a new one in`);
|
|
101
|
+
token = randomBytes(32).toString("hex");
|
|
102
|
+
mkdirSync(home, { recursive: true });
|
|
103
|
+
const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
104
|
+
appendFileSync(envFile, `${before && !before.endsWith("\n") ? "\n" : ""}# The host's Arcade callback token (made on first run; it ties this host's transactions together at the Arcade).\nSKEIN_ARC_TOKEN=${token}\n`, { mode: 0o600 });
|
|
105
|
+
o.made?.(envFile);
|
|
106
|
+
}
|
|
79
107
|
const events = get("SKEIN_ARC_EVENTS_URL"), callbackUrl = get("SKEIN_ARC_CALLBACK_URL");
|
|
80
108
|
return { url: url.replace(/\/+$/, ""), token, ...(events ? { events } : {}), ...(callbackUrl ? { callbackUrl } : {}) };
|
|
81
109
|
}
|
package/dist/host/cli.d.ts
CHANGED
|
@@ -39,6 +39,11 @@ export interface Host {
|
|
|
39
39
|
port?: number;
|
|
40
40
|
stop(): Promise<void>;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* #156: the host's Teranode headers tracker (`skein-host run`): on unless SKEIN_TERANODE=off, where the
|
|
44
|
+
* network has bootstrap peers or SKEIN_TERANODE_BOOTSTRAP names some, or a hub is known for the first sync.
|
|
45
|
+
*/
|
|
46
|
+
export declare function teranodeOptions(v: Record<string, string | undefined>, home: string): Pick<RouterOptions, "teranode">;
|
|
42
47
|
/** The default image (#89): one genesis for everyone, no owner in it, a claim row (boot.ts). */
|
|
43
48
|
export { DEFAULT_IMAGE };
|
|
44
49
|
/**
|
package/dist/host/cli.js
CHANGED
|
@@ -130,7 +130,10 @@
|
|
|
130
130
|
// SKEIN_STORE_ROCKSDB and SKEIN_STORE. Unset, and no native kernel binary here (the npm package
|
|
131
131
|
// ships none): $SKEIN_HOME/store.sqlite
|
|
132
132
|
// SKEIN_ARC_URL the host's Arcade (#58, #65, arc.ts): where the instances' broadcast events go (a durable
|
|
133
|
-
// queue), one status subscription, the instances' status provider
|
|
133
|
+
// queue), one status subscription, the instances' status provider. Unset: the network's (arc.ts DEFAULT_ARC:
|
|
134
|
+
// test https://testnet.arcade.1sat.app, main https://arcade.1sat.app, regtest none); `off`: none.
|
|
135
|
+
// SKEIN_ARC_TOKEN its one callback token (required with a named URL; with the network's and none set,
|
|
136
|
+
// made once and appended to $SKEIN_HOME/host.env, read from there after);
|
|
134
137
|
// SKEIN_ARC_EVENTS_URL its SSE service (default <url>/events); SKEIN_ARC_CALLBACK_URL where Arcade
|
|
135
138
|
// posts webhooks (this router's /arc/callback as Arcade reaches it; unset: SSE only)
|
|
136
139
|
// SKEIN_BILLING `off`: this host bills no one (#130, billing.ts); else it bills a skein whose host row names its
|
|
@@ -144,6 +147,19 @@
|
|
|
144
147
|
// and the default image's chain part (#132, image-chain.ts): every header it brings is
|
|
145
148
|
// appended to the image, which a chaintracks URL's history (`…/tip/stream` → `…/headers`)
|
|
146
149
|
// fills from genesis at start
|
|
150
|
+
// SKEIN_TERANODE #156: `off` turns the host's headers tracker over the Teranode P2P network off (teranode.ts;
|
|
151
|
+
// `skein-host run` only). On by default where the network has bootstrap peers (main, test):
|
|
152
|
+
// it joins the network's block topic (`teranode/bitcoin/1.0.0/<network>-block`), takes each
|
|
153
|
+
// announced header into the same intake as SKEIN_HEADERS_URL's, crawls a gap back from the
|
|
154
|
+
// announcer's data hub, and, with an empty image chain, syncs the whole chain first from the
|
|
155
|
+
// network's default hub (gorillanode). It rides the host node (the host key's, one per host:
|
|
156
|
+
// the instances' overlay on the same node), its block-topic peers from the
|
|
157
|
+
// bootstrap list and GossipSub (no Teranode DHT; the overlay's on its skein DHT, /skein/kad/<network>/1.0.0)
|
|
158
|
+
// SKEIN_TERANODE_BOOTSTRAP more Teranode bootstrap peers (multiaddrs, comma separated) beside the network's
|
|
159
|
+
// (/dnsaddr/<mainnet|testnet>.bootstrap.teranode.bsvb.tech); regtest has none: set it to run one
|
|
160
|
+
// SKEIN_HEADERS_HUB the data hub the tracker's first sync reads (a Teranode asset API: `bestblockheader`,
|
|
161
|
+
// `headers/<hash>?n=`) over the network's default (https://<mainnet|testnet>.gorillanode.io/api/v1);
|
|
162
|
+
// regtest has none: without one the first sync waits for the first announcement's hub
|
|
147
163
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
148
164
|
import { tmpdir } from "node:os";
|
|
149
165
|
import { CID as CIDClass, decode as decodeCbor, parse as parseCid } from "../runtime/cid.js";
|
|
@@ -162,6 +178,7 @@ import { CONTROL_SOCKET, controlRequest } from "./control.js";
|
|
|
162
178
|
import { homeOf as homeOfVars, identityOf, operatorKey, withHostEnv } from "./hostenv.js";
|
|
163
179
|
import { hostArcConfig } from "./arc.js";
|
|
164
180
|
import { hostP2PConfig, peerIdOf } from "./p2p.js";
|
|
181
|
+
import { DEFAULT_HUBS, TERANODE_BOOTSTRAP } from "./teranode.js";
|
|
165
182
|
import { addressBook } from "./deploy.js";
|
|
166
183
|
import { fetchHttp, hostDomain, Router } from "./router.js";
|
|
167
184
|
import { billingConfig, DEV_BILLING, NSAT } from "./billing.js";
|
|
@@ -822,6 +839,7 @@ function wallets(v, home) {
|
|
|
822
839
|
const signer = signerOf(v, home); // #160: one-key mode (SKEIN_KEY_FILE / SKEIN_KEY), else the master key's
|
|
823
840
|
return {
|
|
824
841
|
walletFor: (row) => signer.wallet(row.handle), peerKeyFor: (handle) => signer.peerKey(handle), providerKeyFor: (name) => signer.providerKey(name),
|
|
842
|
+
hostPeerKey: signer.hostPeerKey(), // #156: the host node's (the tracker rides it; a one-key host's instances share it)
|
|
825
843
|
// #162: a one-key host claims the instances it creates with its own key.
|
|
826
844
|
...(signer.shared ? { claimer: signer.wallet("") } : {}),
|
|
827
845
|
};
|
|
@@ -850,11 +868,26 @@ function routerOptions(db, env) {
|
|
|
850
868
|
...(Number(v.SKEIN_ANSWER_WAIT_MS) > 0 ? { answerWaitMs: Number(v.SKEIN_ANSWER_WAIT_MS) } : {}),
|
|
851
869
|
libp2p: hostP2PConfig(v, home),
|
|
852
870
|
headersFeed: v.SKEIN_HEADERS_URL || undefined,
|
|
853
|
-
arc: hostArcConfig(v, home),
|
|
871
|
+
arc: hostArcConfig(v, home, { made: (f) => env.out(`skein-host: the host's Arcade callback token made and kept in ${f} (SKEIN_ARC_TOKEN)`) }),
|
|
854
872
|
billing: billingConfig(v),
|
|
855
873
|
log: (source, line) => env.out(`[${source}] ${line}`),
|
|
856
874
|
};
|
|
857
875
|
}
|
|
876
|
+
/**
|
|
877
|
+
* #156: the host's Teranode headers tracker (`skein-host run`): on unless SKEIN_TERANODE=off, where the
|
|
878
|
+
* network has bootstrap peers or SKEIN_TERANODE_BOOTSTRAP names some, or a hub is known for the first sync.
|
|
879
|
+
*/
|
|
880
|
+
export function teranodeOptions(v, home) {
|
|
881
|
+
if ((v.SKEIN_TERANODE ?? "").toLowerCase() === "off")
|
|
882
|
+
return {};
|
|
883
|
+
const net = hostNetwork(v);
|
|
884
|
+
const named = (v.SKEIN_TERANODE_BOOTSTRAP ?? "").split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
885
|
+
const bootstrap = [...new Set([...(TERANODE_BOOTSTRAP[net] ?? []), ...named])]; // #156: the network's and the named ones
|
|
886
|
+
const hub = (v.SKEIN_HEADERS_HUB || DEFAULT_HUBS[net])?.replace(/\/+$/, "");
|
|
887
|
+
if (!bootstrap.length && !hub)
|
|
888
|
+
return {};
|
|
889
|
+
return { teranode: { bootstrap, ...(hub ? { hub } : {}), peersFile: join(home, "teranode-peers.json") } };
|
|
890
|
+
}
|
|
858
891
|
/** `skein-host billing [handle]` (#130): what host.db holds of billing — the instances, their ticks, their payments. */
|
|
859
892
|
function billingCmd(db, rest, env) {
|
|
860
893
|
const [handle] = rest;
|
|
@@ -1159,7 +1192,7 @@ async function migrateStoreCmd(db, rest, env) {
|
|
|
1159
1192
|
export async function runHost(db, env, o = {}) {
|
|
1160
1193
|
const v = env.vars;
|
|
1161
1194
|
const home = homeOf(v);
|
|
1162
|
-
const router = new Router({ ...routerOptions(db, env), ...o.router });
|
|
1195
|
+
const router = new Router({ ...routerOptions(db, env), ...teranodeOptions(v, home), ...o.router });
|
|
1163
1196
|
const mport = Number(v.SKEIN_ROUTER_PORT ?? 8100);
|
|
1164
1197
|
const binds = (v.SKEIN_ROUTER_BIND ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1165
1198
|
const mserver = await router.listen(mport, binds.length ? binds : undefined).then((s) => s, (e) => { env.err(`skein-host: router: ${e.message}`); return undefined; });
|
|
@@ -1178,7 +1211,7 @@ export async function runHost(db, env, o = {}) {
|
|
|
1178
1211
|
if (router.arc)
|
|
1179
1212
|
env.out(`skein-host: broadcaster: broadcast events → Arcade ${router.o.arc.url}; proofs and statuses (the status provider) from ${router.arc.eventsUrl()}${router.o.arc.callbackUrl ? ` and webhooks at /arc/callback (${router.o.arc.callbackUrl})` : ""}`);
|
|
1180
1213
|
else
|
|
1181
|
-
env.out(
|
|
1214
|
+
env.out(`skein-host: no Arcade (SKEIN_ARC_URL ${(v.SKEIN_ARC_URL ?? "").toLowerCase() === "off" ? "off" : `unset, and network ${hostNetwork(v)} has none`}): a broadcast event is dropped; a new genesis names no status provider`);
|
|
1182
1215
|
await router.start();
|
|
1183
1216
|
// #142: the first run makes the host skein (owned by the operator's key); later runs start what exists.
|
|
1184
1217
|
if (!db.hostSkein() && o.firstRun !== false) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Libp2p } from "@libp2p/interface";
|
|
2
|
+
export declare const REGTEST_GENESIS: Buffer<ArrayBuffer>;
|
|
3
|
+
/** A regtest header on `prev` (its merkle root `root`, else one from `salt`). */
|
|
4
|
+
export declare function mineHeader(prev: Uint8Array, salt: number | string, root?: Uint8Array): Buffer;
|
|
5
|
+
export declare class FakeTeranode {
|
|
6
|
+
/** The chain the hub serves (height = index). */
|
|
7
|
+
chain: Buffer[];
|
|
8
|
+
node: Libp2p;
|
|
9
|
+
hub: string;
|
|
10
|
+
/** Every hub request's path and query, in order. */
|
|
11
|
+
readonly asked: string[];
|
|
12
|
+
readonly topic: string;
|
|
13
|
+
private server?;
|
|
14
|
+
constructor(network?: string);
|
|
15
|
+
static start(network?: string): Promise<FakeTeranode>;
|
|
16
|
+
private pubsub;
|
|
17
|
+
/** The node's address (the tracker's bootstrap). */
|
|
18
|
+
addr(): string;
|
|
19
|
+
/** Peers subscribed to the block topic. */
|
|
20
|
+
subscribers(): number;
|
|
21
|
+
/** Mine to `height` on the served chain. */
|
|
22
|
+
mineTo(height: number): void;
|
|
23
|
+
/** Announce the header at `height` as a Teranode node does (the bus envelope around a BlockMessage). */
|
|
24
|
+
announce(height: number, o?: {
|
|
25
|
+
hub?: string | null;
|
|
26
|
+
raw?: unknown;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// A fake Teranode network for the tests (#156; fake-arcade.ts is the pattern):
|
|
2
|
+
// one js-libp2p node on 127.0.0.1 (TCP, noise, yamux, GossipSub with
|
|
3
|
+
// StrictSign) that publishes block announcements on the block topic as the
|
|
4
|
+
// Teranode message bus does (`{"name", "data": <base64 BlockMessage JSON>}`),
|
|
5
|
+
// and a data hub (the Teranode asset API's two reads the tracker uses:
|
|
6
|
+
// `GET /bestblockheader` (+ `/json`) and `GET /headers/<hash>?n=` — child to
|
|
7
|
+
// parent, binary) serving a regtest chain the test mines. No DHT: skein nodes
|
|
8
|
+
// find Teranode peers by their bootstrap list and GossipSub alone (2026-11-02).
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { noise } from "@chainsafe/libp2p-noise";
|
|
12
|
+
import { yamux } from "@chainsafe/libp2p-yamux";
|
|
13
|
+
import { gossipsub } from "@libp2p/gossipsub";
|
|
14
|
+
import { identify } from "@libp2p/identify";
|
|
15
|
+
import { tcp } from "@libp2p/tcp";
|
|
16
|
+
import { createLibp2p } from "libp2p";
|
|
17
|
+
import { headerProblem } from "./image-chain.js";
|
|
18
|
+
import { blockTopic } from "./teranode.js";
|
|
19
|
+
const sha256d = (b) => createHash("sha256").update(createHash("sha256").update(b).digest()).digest();
|
|
20
|
+
const display = (h) => Buffer.from(h).reverse().toString("hex");
|
|
21
|
+
export const REGTEST_GENESIS = Buffer.from("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000", "hex");
|
|
22
|
+
/** A regtest header on `prev` (its merkle root `root`, else one from `salt`). */
|
|
23
|
+
export function mineHeader(prev, salt, root) {
|
|
24
|
+
const h = Buffer.alloc(80);
|
|
25
|
+
h.writeInt32LE(1, 0);
|
|
26
|
+
Buffer.from(prev).copy(h, 4);
|
|
27
|
+
Buffer.from(root ?? sha256d(Buffer.from(`block ${salt}`))).copy(h, 36);
|
|
28
|
+
h.writeUInt32LE(1_790_300_000 + (typeof salt === "number" ? salt : 7) * 600, 68);
|
|
29
|
+
h.writeUInt32LE(0x207fffff, 72);
|
|
30
|
+
for (let nonce = 0;; nonce++) {
|
|
31
|
+
h.writeUInt32LE(nonce, 76);
|
|
32
|
+
if (!headerProblem(h))
|
|
33
|
+
return h;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class FakeTeranode {
|
|
37
|
+
/** The chain the hub serves (height = index). */
|
|
38
|
+
chain = [REGTEST_GENESIS];
|
|
39
|
+
node;
|
|
40
|
+
hub = "";
|
|
41
|
+
/** Every hub request's path and query, in order. */
|
|
42
|
+
asked = [];
|
|
43
|
+
topic;
|
|
44
|
+
server;
|
|
45
|
+
constructor(network = "regtest") { this.topic = blockTopic(network); }
|
|
46
|
+
static async start(network = "regtest") {
|
|
47
|
+
const f = new FakeTeranode(network);
|
|
48
|
+
f.node = await createLibp2p({
|
|
49
|
+
addresses: { listen: ["/ip4/127.0.0.1/tcp/0"] },
|
|
50
|
+
transports: [tcp()], connectionEncrypters: [noise()], streamMuxers: [yamux()],
|
|
51
|
+
services: {
|
|
52
|
+
identify: identify(), pubsub: gossipsub({ globalSignaturePolicy: "StrictSign", allowPublishToZeroTopicPeers: true, emitSelf: false }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
f.pubsub().subscribe(f.topic);
|
|
56
|
+
f.server = createServer((req, res) => {
|
|
57
|
+
const u = new URL(req.url ?? "/", "http://x");
|
|
58
|
+
f.asked.push(`${u.pathname}${u.search}`);
|
|
59
|
+
const best = f.chain.at(-1);
|
|
60
|
+
if (u.pathname === "/bestblockheader")
|
|
61
|
+
return void res.writeHead(200, { "content-type": "application/octet-stream" }).end(best);
|
|
62
|
+
if (u.pathname === "/bestblockheader/json")
|
|
63
|
+
return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ hash: display(sha256d(best)), height: f.chain.length - 1 }));
|
|
64
|
+
const m = /^\/headers\/([0-9a-f]{64})$/.exec(u.pathname);
|
|
65
|
+
if (m) {
|
|
66
|
+
const at = f.chain.findIndex((h) => display(sha256d(h)) === m[1]);
|
|
67
|
+
if (at < 0)
|
|
68
|
+
return void res.writeHead(404).end();
|
|
69
|
+
const n = Math.min(Number(u.searchParams.get("n") ?? 1), 10_000);
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let h = at; h >= 0 && out.length < n; h--)
|
|
72
|
+
out.push(f.chain[h]);
|
|
73
|
+
return void res.writeHead(200, { "content-type": "application/octet-stream" }).end(Buffer.concat(out));
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(404).end();
|
|
76
|
+
});
|
|
77
|
+
await new Promise((r) => f.server.listen(0, "127.0.0.1", r));
|
|
78
|
+
f.hub = `http://127.0.0.1:${f.server.address().port}`;
|
|
79
|
+
return f;
|
|
80
|
+
}
|
|
81
|
+
pubsub() { return this.node.services.pubsub; }
|
|
82
|
+
/** The node's address (the tracker's bootstrap). */
|
|
83
|
+
addr() { return this.node.getMultiaddrs().find((a) => a.toString().includes("127.0.0.1")).toString(); }
|
|
84
|
+
/** Peers subscribed to the block topic. */
|
|
85
|
+
subscribers() { return this.pubsub().getSubscribers(this.topic).length; }
|
|
86
|
+
/** Mine to `height` on the served chain. */
|
|
87
|
+
mineTo(height) { while (this.chain.length <= height)
|
|
88
|
+
this.chain.push(mineHeader(sha256d(this.chain.at(-1)), this.chain.length)); }
|
|
89
|
+
/** Announce the header at `height` as a Teranode node does (the bus envelope around a BlockMessage). */
|
|
90
|
+
async announce(height, o = {}) {
|
|
91
|
+
const h = this.chain[height];
|
|
92
|
+
const msg = o.raw ?? { PeerID: this.node.peerId.toString(), ClientName: "fake", DataHubURL: o.hub === null ? "" : (o.hub ?? this.hub), Hash: display(sha256d(h)), Height: height, Header: h.toString("hex"), Coinbase: "" };
|
|
93
|
+
const data = Buffer.from(typeof msg === "string" ? msg : JSON.stringify({ name: "fake", data: Buffer.from(JSON.stringify(msg)).toString("base64") }));
|
|
94
|
+
await this.pubsub().publish(this.topic, data);
|
|
95
|
+
}
|
|
96
|
+
async stop() {
|
|
97
|
+
await this.node.stop();
|
|
98
|
+
await new Promise((r) => this.server?.close(r));
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/host/feeds.d.ts
CHANGED
|
@@ -107,7 +107,7 @@ export declare class Feeds {
|
|
|
107
107
|
* chain part grows by it): every header the stream brings, as well as the
|
|
108
108
|
* subscribers'. The connection stays open while a listener is on it.
|
|
109
109
|
*/
|
|
110
|
-
listen(url: string, fn: (raws: Uint8Array[]) => void): void;
|
|
110
|
+
listen(url: string, fn: (raws: Uint8Array[], at?: number) => Promise<void> | void): void;
|
|
111
111
|
/** Whether `handle` is subscribed to the host's own feed. */
|
|
112
112
|
hosts(handle: string): boolean;
|
|
113
113
|
/** Subscribe `handle` to what it declared and the host's feed for it, and to nothing else. */
|
|
@@ -124,4 +124,12 @@ export declare class Feeds {
|
|
|
124
124
|
pending(handle: string): number;
|
|
125
125
|
private drain;
|
|
126
126
|
private dispatch;
|
|
127
|
+
/**
|
|
128
|
+
* #156: headers the host brings itself (the Teranode tracker) as if `url`'s stream had: to its
|
|
129
|
+
* listener (the image chain, the host chain), then to its subscribers in runs of BACKFILL_RUN.
|
|
130
|
+
* `at`: the height of the first (a run that may replace from there).
|
|
131
|
+
*/
|
|
132
|
+
inject(url: string, raws: Uint8Array[], at?: number): Promise<void>;
|
|
133
|
+
/** The listener first (awaited: the host chain holds the run before an instance is told), then every subscriber. */
|
|
134
|
+
private deliver;
|
|
127
135
|
}
|
package/dist/host/feeds.js
CHANGED
|
@@ -341,7 +341,7 @@ export class Feeds {
|
|
|
341
341
|
if (s.subscribers.has(handle) && !specs.some((f) => f.kind === "headers" && f.url === url))
|
|
342
342
|
s.subscribers.delete(handle);
|
|
343
343
|
if (!s.subscribers.size && !this.listeners.has(url)) {
|
|
344
|
-
void s.stream
|
|
344
|
+
void s.stream?.stop();
|
|
345
345
|
this.sse.delete(url);
|
|
346
346
|
}
|
|
347
347
|
}
|
|
@@ -354,6 +354,11 @@ export class Feeds {
|
|
|
354
354
|
/** The one connection to `url`, opened if it is not. */
|
|
355
355
|
connect(url) {
|
|
356
356
|
let s = this.sse.get(url);
|
|
357
|
+
if (!s && !/^https?:/i.test(url)) {
|
|
358
|
+
// #156: a source the host feeds itself (the Teranode tracker, `inject`): subscribers, no connection.
|
|
359
|
+
s = { subscribers: new Map() };
|
|
360
|
+
this.sse.set(url, s);
|
|
361
|
+
}
|
|
357
362
|
if (!s) {
|
|
358
363
|
const subscribers = new Map();
|
|
359
364
|
const stream = new SseStream(url, (ev) => this.dispatch(url, subscribers, ev.data), {
|
|
@@ -370,7 +375,7 @@ export class Feeds {
|
|
|
370
375
|
/** Close every connection (no reconnect); resolves once their loops have ended. */
|
|
371
376
|
async stop() {
|
|
372
377
|
this.stopped = true;
|
|
373
|
-
const streams = [...this.sse.values()].map((s) => s.stream
|
|
378
|
+
const streams = [...this.sse.values()].map((s) => s.stream?.stop());
|
|
374
379
|
this.sse.clear();
|
|
375
380
|
await Promise.all(streams);
|
|
376
381
|
}
|
|
@@ -407,7 +412,7 @@ export class Feeds {
|
|
|
407
412
|
q.running = false;
|
|
408
413
|
}
|
|
409
414
|
}
|
|
410
|
-
dispatch(url, subscribers, data) {
|
|
415
|
+
async dispatch(url, subscribers, data) {
|
|
411
416
|
let dropped = false;
|
|
412
417
|
const hs = headersOf(data, (why) => { dropped = true; this.say("router", `feed ${url}: ${why}`); });
|
|
413
418
|
if (!hs.length) {
|
|
@@ -415,8 +420,28 @@ export class Feeds {
|
|
|
415
420
|
this.say("router", `feed ${url}: an event with no header in it: ignored`);
|
|
416
421
|
return;
|
|
417
422
|
}
|
|
418
|
-
this.
|
|
423
|
+
await this.deliver(url, subscribers, hs);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* #156: headers the host brings itself (the Teranode tracker) as if `url`'s stream had: to its
|
|
427
|
+
* listener (the image chain, the host chain), then to its subscribers in runs of BACKFILL_RUN.
|
|
428
|
+
* `at`: the height of the first (a run that may replace from there).
|
|
429
|
+
*/
|
|
430
|
+
async inject(url, raws, at) {
|
|
431
|
+
if (this.stopped || !raws.length)
|
|
432
|
+
return;
|
|
433
|
+
await this.deliver(url, this.connect(url).subscribers, raws, at);
|
|
434
|
+
}
|
|
435
|
+
/** The listener first (awaited: the host chain holds the run before an instance is told), then every subscriber. */
|
|
436
|
+
async deliver(url, subscribers, hs, at) {
|
|
437
|
+
try {
|
|
438
|
+
await this.listeners.get(url)?.(hs, at);
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
this.say("router", `feed ${url}: ${hs.length} header${hs.length === 1 ? "" : "s"} not taken: ${e.message}`);
|
|
442
|
+
}
|
|
419
443
|
for (const [handle, box] of subscribers)
|
|
420
|
-
|
|
444
|
+
for (let i = 0; i < hs.length; i += BACKFILL_RUN)
|
|
445
|
+
this.push(handle, box, hs.slice(i, i + BACKFILL_RUN));
|
|
421
446
|
}
|
|
422
447
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
1
2
|
import type { Store } from "../runtime/store.ts";
|
|
2
3
|
import { type BootSource } from "./boot.ts";
|
|
3
4
|
import type { HostDb } from "./instances.ts";
|
|
@@ -33,6 +34,10 @@ export declare class ImageChain {
|
|
|
33
34
|
private stat?;
|
|
34
35
|
/** #142, #152: the overlay images' static parts (images/host, a directory with no `bin/`), each merged over the default image once. */
|
|
35
36
|
private merged;
|
|
37
|
+
/** Boots reading the image now (`hold`): while any, a write's drops wait in `pending`. */
|
|
38
|
+
private holds;
|
|
39
|
+
/** The objects writes replaced while a boot read the image: dropped once none does. */
|
|
40
|
+
private pending;
|
|
36
41
|
/** Settles once the first sync (start) is done: what a creation waits for. */
|
|
37
42
|
ready: Promise<void>;
|
|
38
43
|
constructor(o: ImageChainOptions);
|
|
@@ -47,6 +52,24 @@ export declare class ImageChain {
|
|
|
47
52
|
height: number;
|
|
48
53
|
hash: string;
|
|
49
54
|
} | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The height of a header held within `depth` of the tip (default two blocks' worth), by its hash
|
|
57
|
+
* (display hex); undefined when none there is it. An index over that window, rebuilt when the tip moves.
|
|
58
|
+
*/
|
|
59
|
+
find(hash: string, depth?: number): number | undefined;
|
|
60
|
+
private window?;
|
|
61
|
+
/**
|
|
62
|
+
* The chain part as objects (#156: what an empty host chain imports): the `chain` tree's CID and
|
|
63
|
+
* every object under it (the tree, `headers` and its blocks, `tip`), as host.db holds them.
|
|
64
|
+
*/
|
|
65
|
+
chainPart(): {
|
|
66
|
+
chain: CID;
|
|
67
|
+
objects: Array<[CID, Uint8Array]>;
|
|
68
|
+
} | undefined;
|
|
69
|
+
/** The header held at a height (raw, a copy), or undefined. */
|
|
70
|
+
headerAt(height: number): Uint8Array | undefined;
|
|
71
|
+
/** The headers held from `from` to the tip, `run` at a time (copies; the chain read as it stands at each run). */
|
|
72
|
+
runs(from: number, run: number): Generator<Uint8Array[]>;
|
|
50
73
|
/** The header at a height (raw), from its block. */
|
|
51
74
|
private at;
|
|
52
75
|
/** One at a time: every change to the chain part goes through here. */
|
|
@@ -60,6 +83,13 @@ export declare class ImageChain {
|
|
|
60
83
|
*/
|
|
61
84
|
put(height: number, raws: Uint8Array[]): Promise<boolean>;
|
|
62
85
|
private write;
|
|
86
|
+
/**
|
|
87
|
+
* A boot reads the image (its root, then every object under it) from `source()` on: hold it from
|
|
88
|
+
* before `source()` until the walk is done. Until the last hold is released, a write keeps the
|
|
89
|
+
* objects it replaces (their deletes wait); the release returned (once) applies them when no
|
|
90
|
+
* other boot holds.
|
|
91
|
+
*/
|
|
92
|
+
hold(): () => void;
|
|
63
93
|
/** The static part: the image directory, scanned once (boot.ts imageDir: the apps it names beside it too). */
|
|
64
94
|
private static;
|
|
65
95
|
/**
|
package/dist/host/image-chain.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
// two trees above them and the root; every full block stays as it is. The
|
|
20
20
|
// chain part's objects live in host.db (`image_blocks`: only the current ones;
|
|
21
21
|
// a replaced object is deleted with the write that replaces it), the current
|
|
22
|
-
// root and `chain` tree in `host_settings` (`image`, `image_chain`).
|
|
22
|
+
// root and `chain` tree in `host_settings` (`image`, `image_chain`). While a
|
|
23
|
+
// boot reads the image (`hold()`, Router.createInstance), a write puts its new
|
|
24
|
+
// objects and settings but keeps the replaced ones: their deletes wait for the
|
|
25
|
+
// next write with no boot in flight, or the last boot's release.
|
|
23
26
|
//
|
|
24
27
|
// Where the headers come from: the host's headers feed (SKEIN_HEADERS_URL,
|
|
25
28
|
// feeds.ts), each one appended as it arrives; and its history, from the same
|
|
@@ -95,6 +98,10 @@ export class ImageChain {
|
|
|
95
98
|
stat;
|
|
96
99
|
/** #142, #152: the overlay images' static parts (images/host, a directory with no `bin/`), each merged over the default image once. */
|
|
97
100
|
merged = new Map();
|
|
101
|
+
/** Boots reading the image now (`hold`): while any, a write's drops wait in `pending`. */
|
|
102
|
+
holds = 0;
|
|
103
|
+
/** The objects writes replaced while a boot read the image: dropped once none does. */
|
|
104
|
+
pending = new Set();
|
|
98
105
|
/** Settles once the first sync (start) is done: what a creation waits for. */
|
|
99
106
|
ready = Promise.resolve();
|
|
100
107
|
constructor(o) {
|
|
@@ -132,6 +139,54 @@ export class ImageChain {
|
|
|
132
139
|
const h = this.height();
|
|
133
140
|
return h < 0 ? undefined : { height: h, hash: display(sha256d(this.last.subarray(this.last.length - SIZE))) };
|
|
134
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The height of a header held within `depth` of the tip (default two blocks' worth), by its hash
|
|
144
|
+
* (display hex); undefined when none there is it. An index over that window, rebuilt when the tip moves.
|
|
145
|
+
*/
|
|
146
|
+
find(hash, depth = 2 * PER_BLOCK) {
|
|
147
|
+
const t = this.tip();
|
|
148
|
+
if (!t)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (this.window?.tip !== t.hash || this.window.depth !== depth) {
|
|
151
|
+
const map = new Map();
|
|
152
|
+
for (let h = t.height; h >= Math.max(0, t.height - depth); h--)
|
|
153
|
+
map.set(display(sha256d(this.at(h))), h);
|
|
154
|
+
this.window = { tip: t.hash, depth, map };
|
|
155
|
+
}
|
|
156
|
+
return this.window.map.get(hash);
|
|
157
|
+
}
|
|
158
|
+
window;
|
|
159
|
+
/**
|
|
160
|
+
* The chain part as objects (#156: what an empty host chain imports): the `chain` tree's CID and
|
|
161
|
+
* every object under it (the tree, `headers` and its blocks, `tip`), as host.db holds them.
|
|
162
|
+
*/
|
|
163
|
+
chainPart() {
|
|
164
|
+
const c = this.o.db.setting("image_chain");
|
|
165
|
+
if (!c)
|
|
166
|
+
return undefined;
|
|
167
|
+
const chain = Cid.parse(c);
|
|
168
|
+
const objects = [[chain, this.object(chain)]];
|
|
169
|
+
for (const e of parseTree(this.object(chain))) {
|
|
170
|
+
objects.push([e.cid, this.object(e.cid)]);
|
|
171
|
+
if (e.name === "headers")
|
|
172
|
+
for (const b of parseTree(this.object(e.cid)))
|
|
173
|
+
objects.push([b.cid, this.object(b.cid)]);
|
|
174
|
+
}
|
|
175
|
+
return { chain, objects };
|
|
176
|
+
}
|
|
177
|
+
/** The header held at a height (raw, a copy), or undefined. */
|
|
178
|
+
headerAt(height) {
|
|
179
|
+
return height >= 0 && height <= this.height() ? new Uint8Array(this.at(height)) : undefined;
|
|
180
|
+
}
|
|
181
|
+
/** The headers held from `from` to the tip, `run` at a time (copies; the chain read as it stands at each run). */
|
|
182
|
+
*runs(from, run) {
|
|
183
|
+
for (let h = Math.max(0, from); h <= this.height(); h += run) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (let i = h; i < h + run && i <= this.height(); i++)
|
|
186
|
+
out.push(new Uint8Array(this.at(i)));
|
|
187
|
+
yield out;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
135
190
|
/** The header at a height (raw), from its block. */
|
|
136
191
|
at(height) {
|
|
137
192
|
const b = Math.floor(height / PER_BLOCK);
|
|
@@ -204,13 +259,40 @@ export class ImageChain {
|
|
|
204
259
|
const oldRoot = this.o.db.setting("image");
|
|
205
260
|
if (oldRoot)
|
|
206
261
|
drops.add(oldRoot);
|
|
262
|
+
for (const d of this.pending)
|
|
263
|
+
drops.add(d);
|
|
207
264
|
for (const k of puts.keys())
|
|
208
265
|
drops.delete(k);
|
|
209
|
-
|
|
266
|
+
// A boot reading the image (hold): the replaced objects stay until it is done.
|
|
267
|
+
const now = this.holds === 0 ? drops : new Set();
|
|
268
|
+
this.pending = this.holds === 0 ? new Set() : drops;
|
|
269
|
+
this.o.db.writeImage(puts, now, { image: root.cid.toString(), image_chain: chain.cid.toString() });
|
|
210
270
|
this.blocks = blocks;
|
|
211
271
|
this.last = Buffer.from(last);
|
|
212
272
|
return true;
|
|
213
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* A boot reads the image (its root, then every object under it) from `source()` on: hold it from
|
|
276
|
+
* before `source()` until the walk is done. Until the last hold is released, a write keeps the
|
|
277
|
+
* objects it replaces (their deletes wait); the release returned (once) applies them when no
|
|
278
|
+
* other boot holds.
|
|
279
|
+
*/
|
|
280
|
+
hold() {
|
|
281
|
+
this.holds++;
|
|
282
|
+
let done = false;
|
|
283
|
+
return () => {
|
|
284
|
+
if (done)
|
|
285
|
+
return;
|
|
286
|
+
done = true;
|
|
287
|
+
if (--this.holds === 0 && this.pending.size)
|
|
288
|
+
void this.serial(() => {
|
|
289
|
+
if (this.holds !== 0 || !this.pending.size)
|
|
290
|
+
return;
|
|
291
|
+
this.o.db.writeImage(new Map(), this.pending, {});
|
|
292
|
+
this.pending = new Set();
|
|
293
|
+
}).catch((e) => this.say(`the image's replaced objects not dropped: ${e.message}`));
|
|
294
|
+
};
|
|
295
|
+
}
|
|
214
296
|
/** The static part: the image directory, scanned once (boot.ts imageDir: the apps it names beside it too). */
|
|
215
297
|
static() {
|
|
216
298
|
this.stat ??= (async () => {
|