reeflog-client 0.0.0-stage → 0.7.21
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/LICENSE +21 -0
- package/README.md +1875 -2
- package/index.js +597 -0
- package/package.json +34 -4
package/index.js
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// The Node client for reeflog — the "SDKs" the README's free tier promises,
|
|
2
|
+
// shipped as one file with no dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists (2026-11-07): reeflog's free tier claims "daemon, journal,
|
|
5
|
+
// CLI, SDKs, webhook fan-out …" and a third party who wanted to consume the
|
|
6
|
+
// mailbox from Node had to hand-roll `spawn('reeflog tail')` themselves and,
|
|
7
|
+
// worse, get the EXIT-CODE contract right by hand. That contract is the whole
|
|
8
|
+
// product:
|
|
9
|
+
//
|
|
10
|
+
// exit 0 — the batch is fully delivered, the cursor advanced;
|
|
11
|
+
// exit 3 — the delivery HELD at a row whose account envelope is still owed
|
|
12
|
+
// (tail), or the scan proves nothing (verify/repair/hydrate).
|
|
13
|
+
// Rows before the hold WERE delivered; nothing at or after it was
|
|
14
|
+
// consumed. Treating exit 3 as "failure, retry the batch" replays
|
|
15
|
+
// rows; treating it as success silently drops the owed row;
|
|
16
|
+
// exit 1 — the run refused (no journal, unknown flag, missing value).
|
|
17
|
+
//
|
|
18
|
+
// The client maps that contract into types instead of letting each consumer
|
|
19
|
+
// re-derive it from a blog post. It never opens the journal: every call goes
|
|
20
|
+
// through the `reeflog` binary, so there is exactly one implementation of the
|
|
21
|
+
// reader contract and no drift between a library's idea of the schema and the
|
|
22
|
+
// daemon's.
|
|
23
|
+
//
|
|
24
|
+
// Cursor files are the binary's (it reads and writes them); the client only
|
|
25
|
+
// picks a default path per channel so two consumers of the same journal do
|
|
26
|
+
// not share a cursor by accident. Pass your own `cursor` to identify a
|
|
27
|
+
// consumer.
|
|
28
|
+
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import http from 'node:http';
|
|
34
|
+
import https from 'node:https';
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { gunzipSync } from 'node:zlib';
|
|
37
|
+
|
|
38
|
+
/** Exit codes the reeflog CLI uses, mirrored as constants (not guessed:
|
|
39
|
+
* documented in the README and pinned by tests/arg_parsing.rs). */
|
|
40
|
+
export const EXIT = {
|
|
41
|
+
OK: 0,
|
|
42
|
+
REFUSED: 1,
|
|
43
|
+
INCOMPLETE: 3,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** A refusal or incomplete pass, carrying the binary's own words. */
|
|
47
|
+
export class ReeflogError extends Error {
|
|
48
|
+
constructor(message, { exitCode, stderr, args } = {}) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'ReeflogError';
|
|
51
|
+
this.exitCode = exitCode;
|
|
52
|
+
this.stderr = stderr;
|
|
53
|
+
this.args = args;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The single call everything else is built on: run the binary, collect
|
|
58
|
+
* stdout/stderr, and hand back { code, stdout, stderr, lines } with the
|
|
59
|
+
* refusal already classified. Nothing is thrown on exit 3 — the caller
|
|
60
|
+
* decides what "held" or "incomplete" means for its own channel.
|
|
61
|
+
*
|
|
62
|
+
* CLASSIFIED runs (the typed API above) accept only the codes the product's
|
|
63
|
+
* contract defines: 0 delivered, 1 refused, 3 held/incomplete. Every other
|
|
64
|
+
* close — the journal-write failure's exit 2, an old binary's usage exit 64
|
|
65
|
+
* (version skew), a panic's 101, a kill by signal (close code null) — is a
|
|
66
|
+
* REFUSAL carrying that code and the binary's last words. Before this rule
|
|
67
|
+
* `run()` treated "not 1 and not 3" as success, so a run that died AFTER
|
|
68
|
+
* printing rows resolved as a delivered batch (held:null = "fully
|
|
69
|
+
* delivered"), and a SIGKILL'd child was dressed up as a forged "exit 1"
|
|
70
|
+
* refusal that named no signal. Both faces measured on the published
|
|
71
|
+
* v0.7.19 tree — research/client-exit-voice-2026-11-12.json.
|
|
72
|
+
*
|
|
73
|
+
* `unclassified` (raw() only) keeps the documented escape hatch: the code
|
|
74
|
+
* comes back as { exitCode } so a caller can implement semantics this file
|
|
75
|
+
* does not know yet. */
|
|
76
|
+
function run(bin, args, { allowExit3 = false, unclassified = false } = {}) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
79
|
+
let stdout = '';
|
|
80
|
+
let stderr = '';
|
|
81
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
82
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
83
|
+
child.on('error', (e) => {
|
|
84
|
+
if (e.code === 'ENOENT') {
|
|
85
|
+
reject(new ReeflogError(
|
|
86
|
+
`reeflog binary not found at "${bin}" — get one without a Rust toolchain: \`import { bootstrap } from 'reeflog-client'; await bootstrap()\` (verifies the release's sha256), or from source: \`cargo install --git https://github.com/auroraxo/reeflog --tag ${packageVersion() || 'v0.7.0'}\`; or pass { bin: "/path/to/reeflog" }`,
|
|
87
|
+
{ args },
|
|
88
|
+
));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
reject(new ReeflogError(`reeflog failed to start: ${e.message}`, { args }));
|
|
92
|
+
});
|
|
93
|
+
child.on('close', (code, signal) => {
|
|
94
|
+
const exitCode = unclassified ? (code ?? 1) : code;
|
|
95
|
+
// A kill (SIGKILL, OOM, Ctrl-C) leaves code=null. The binary never
|
|
96
|
+
// refused anything — say who ended the run, with the stderr it did
|
|
97
|
+
// manage to write, instead of minting "exit 1".
|
|
98
|
+
if (!unclassified && code == null) {
|
|
99
|
+
reject(new ReeflogError(
|
|
100
|
+
`reeflog was killed by ${signal ?? 'an unknown signal'} — no verdict was produced${stderr.trim() ? `: ${stderr.trim()}` : ''}`,
|
|
101
|
+
{ exitCode: null, stderr, args },
|
|
102
|
+
));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (exitCode === 1) {
|
|
106
|
+
// The binary's own refusal, verbatim: "no journal at … — run
|
|
107
|
+
// `reeflog init …`" and friends are the actionable part.
|
|
108
|
+
const first = stderr.split('\n').find((l) => l.trim()) || `exit ${exitCode}`;
|
|
109
|
+
reject(new ReeflogError(first.trim(), { exitCode, stderr, args }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (exitCode === 3 && !allowExit3) {
|
|
113
|
+
reject(new ReeflogError(
|
|
114
|
+
`reeflog pass INCOMPLETE: ${stderr.trim()}`,
|
|
115
|
+
{ exitCode, stderr, args },
|
|
116
|
+
));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!unclassified && exitCode !== 0 && exitCode !== 3) {
|
|
120
|
+
// The contract knows 0/1/3. Anything else is a crashed or mismatched
|
|
121
|
+
// run: name the REAL code and the binary's last words, so a version
|
|
122
|
+
// skew (usage text) or a journal-write failure reaches the caller
|
|
123
|
+
// instead of JSON.parse eating it or a zero-row batch passing as
|
|
124
|
+
// "delivered".
|
|
125
|
+
const first = stderr.split('\n').find((l) => l.trim()) || stderr.trim();
|
|
126
|
+
const hint = exitCode === 64
|
|
127
|
+
? ' (64 = usage error — this binary does not know the invocation; check for client/binary version skew)'
|
|
128
|
+
: '';
|
|
129
|
+
reject(new ReeflogError(
|
|
130
|
+
`reeflog exited with code ${exitCode}${hint} — refusing to call it a verdict${first ? `: ${first}` : ''}`,
|
|
131
|
+
{ exitCode, stderr, args },
|
|
132
|
+
));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
resolve({
|
|
136
|
+
exitCode,
|
|
137
|
+
stdout,
|
|
138
|
+
stderr,
|
|
139
|
+
lines: stdout.split('\n').filter((l) => l.trim()),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const parseJsonLines = (lines) => lines.map((l) => JSON.parse(l));
|
|
146
|
+
|
|
147
|
+
function cursorFor(channel, db) {
|
|
148
|
+
return path.resolve(`${db}.${channel}.cursor`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The numeric POSITION a cursor file holds, in either form it may carry:
|
|
153
|
+
* a bare whole number (the operator's deliberate rewind) or the structured
|
|
154
|
+
* cursor reeflog writes since v0.7.13 (`{"v":1,"channel":…,"journal":…,
|
|
155
|
+
* "value":N}`). The client reports the position, never the encoding.
|
|
156
|
+
* @param {string} file cursor file path
|
|
157
|
+
* @returns {number} the position, 0 when there is no file
|
|
158
|
+
*/
|
|
159
|
+
function cursorPosition(file) {
|
|
160
|
+
if (!fs.existsSync(file)) return 0;
|
|
161
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
162
|
+
if (!raw) return 0;
|
|
163
|
+
if (raw.startsWith('{')) {
|
|
164
|
+
try {
|
|
165
|
+
const v = JSON.parse(raw);
|
|
166
|
+
if (Number.isInteger(v?.value)) return v.value;
|
|
167
|
+
} catch {
|
|
168
|
+
// fall through to 0 — a cursor we cannot read is no position
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const n = Number(raw);
|
|
173
|
+
return Number.isInteger(n) ? n : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class Reeflog {
|
|
177
|
+
/**
|
|
178
|
+
* @param {object} opts
|
|
179
|
+
* @param {string} opts.db path to the journal (created by `reeflog init`)
|
|
180
|
+
* @param {string} [opts.bin] path to the reeflog binary (default: `reeflog` on PATH)
|
|
181
|
+
*/
|
|
182
|
+
constructor({ db, bin = 'reeflog' } = {}) {
|
|
183
|
+
if (!db) throw new ReeflogError('Reeflog({ db }) requires the journal path');
|
|
184
|
+
this.db = db;
|
|
185
|
+
this.bin = bin;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `reeflog status` as parsed JSON: event counts, payload/envelope debt,
|
|
189
|
+
* gap counters, and the exact window the journal claims. */
|
|
190
|
+
async status() {
|
|
191
|
+
const r = await run(this.bin, ['status', '--db', this.db]);
|
|
192
|
+
return JSON.parse(r.stdout);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One batch of events after the cursor — `reeflog tail`.
|
|
197
|
+
*
|
|
198
|
+
* Cursor semantics are the binary's: the file advances to the newest row
|
|
199
|
+
* CONSUMED (delivered, or skipped because a recorded outcome made it
|
|
200
|
+
* permanently undeliverable). On exit 3 the hold row and everything after
|
|
201
|
+
* it are NOT consumed — the next call returns them once the debt is paid
|
|
202
|
+
* (`reeflog hydrate --accounts`), so nothing is ever lost to a consumer
|
|
203
|
+
* that acted on a partial batch.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} [opts]
|
|
206
|
+
* @param {number} [opts.limit=1000]
|
|
207
|
+
* @param {string} [opts.cursor] cursor file; defaults per db+consumer
|
|
208
|
+
* @param {string} [opts.consumer] label for the default cursor file
|
|
209
|
+
* @param {boolean} [opts.withAccounts] hold at a row whose account envelope is owed
|
|
210
|
+
* @param {boolean} [opts.noLogs] identity-only rows (no logs/accounts payload)
|
|
211
|
+
* @returns {Promise<{events: object[], held: object|null, cursor: string, exitCode: number}>}
|
|
212
|
+
* `held` is the row the delivery stopped at (exit 3) or null.
|
|
213
|
+
*/
|
|
214
|
+
async tail({ limit = 1000, cursor, consumer = 'default', withAccounts = false, noLogs = false } = {}) {
|
|
215
|
+
const cursorFile = cursor ?? cursorFor(`tail.${consumer}`, this.db);
|
|
216
|
+
const args = ['tail', '--db', this.db, '--cursor', cursorFile, '--limit', String(limit)];
|
|
217
|
+
if (withAccounts) args.push('--with-accounts');
|
|
218
|
+
if (noLogs) args.push('--no-logs');
|
|
219
|
+
|
|
220
|
+
const r = await run(this.bin, args, { allowExit3: true });
|
|
221
|
+
const events = parseJsonLines(r.lines);
|
|
222
|
+
|
|
223
|
+
// exit 3 on tail means exactly one thing: HELD at an owed row, named in
|
|
224
|
+
// stderr ("HELD at seq N <sig>: …"). The rows printed above it are
|
|
225
|
+
// delivered and the cursor already advanced past them.
|
|
226
|
+
let held = null;
|
|
227
|
+
if (r.exitCode === EXIT.INCOMPLETE) {
|
|
228
|
+
// The binary names the hold as "HELD at seq <n> <signature>: <why>".
|
|
229
|
+
// The signature is base58, so it never carries a colon — stop the match
|
|
230
|
+
// before the colon instead of swallowing it.
|
|
231
|
+
const m = r.stderr.match(/HELD at seq (\d+) ([^\s:]+)/);
|
|
232
|
+
held = m
|
|
233
|
+
? { seq: Number(m[1]), signature: m[2], reason: r.stderr.trim() }
|
|
234
|
+
: { seq: null, signature: null, reason: r.stderr.trim() };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
events,
|
|
239
|
+
held,
|
|
240
|
+
cursor: cursorFile,
|
|
241
|
+
cursorValue: cursorPosition(cursorFile),
|
|
242
|
+
exitCode: r.exitCode,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The retraction channel — `reeflog reorg tail`: events the chain no
|
|
248
|
+
* longer knows, each with the two independent sources that refused it.
|
|
249
|
+
* Separate cursor from `tail` (the channels are separate).
|
|
250
|
+
*/
|
|
251
|
+
async reorgs({ limit = 1000, cursor, consumer = 'default' } = {}) {
|
|
252
|
+
const cursorFile = cursor ?? cursorFor(`reorg.${consumer}`, this.db);
|
|
253
|
+
const r = await run(this.bin, ['reorg', 'tail', '--db', this.db, '--cursor', cursorFile, '--limit', String(limit)]);
|
|
254
|
+
return {
|
|
255
|
+
retractions: parseJsonLines(r.lines),
|
|
256
|
+
cursor: cursorFile,
|
|
257
|
+
cursorValue: cursorPosition(cursorFile),
|
|
258
|
+
exitCode: r.exitCode,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `reeflog verify` — held against the chain. Exit 3 (incomplete scan)
|
|
264
|
+
* throws: an incomplete scan proves NOTHING and must never be mistaken
|
|
265
|
+
* for a clean one. `complete: false` therefore never returns normally.
|
|
266
|
+
*/
|
|
267
|
+
async verify({ pages, sinceSlot, rpc, program, anchorSlot } = {}) {
|
|
268
|
+
const args = ['verify', '--db', this.db];
|
|
269
|
+
if (pages != null) args.push('--pages', String(pages));
|
|
270
|
+
if (sinceSlot != null) args.push('--since-slot', String(sinceSlot));
|
|
271
|
+
if (rpc) args.push('--rpc', rpc);
|
|
272
|
+
if (program) args.push('--program', program);
|
|
273
|
+
if (anchorSlot != null) args.push('--anchor-slot', String(anchorSlot));
|
|
274
|
+
const r = await run(this.bin, args); // exit 3 -> ReeflogError
|
|
275
|
+
return JSON.parse(r.stdout);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `reeflog repair` — fill holes; throws on an incomplete pass. */
|
|
279
|
+
async repair({ pages, sinceSlot, rpc, program, anchorSlot } = {}) {
|
|
280
|
+
const args = ['repair', '--db', this.db];
|
|
281
|
+
if (pages != null) args.push('--pages', String(pages));
|
|
282
|
+
if (sinceSlot != null) args.push('--since-slot', String(sinceSlot));
|
|
283
|
+
if (rpc) args.push('--rpc', rpc);
|
|
284
|
+
if (program) args.push('--program', program);
|
|
285
|
+
if (anchorSlot != null) args.push('--anchor-slot', String(anchorSlot));
|
|
286
|
+
const r = await run(this.bin, args);
|
|
287
|
+
return JSON.parse(r.stdout);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `reeflog hydrate` — pay the payload/envelope debt for owed rows.
|
|
292
|
+
* Throws on an incomplete pass (exit 3), because the debt it reports is
|
|
293
|
+
* real; the counts in the returned summary say what was paid.
|
|
294
|
+
*/
|
|
295
|
+
async hydrate({ limit, rpc, accounts = false, owners = false, accountData = false } = {}) {
|
|
296
|
+
const args = ['hydrate', '--db', this.db];
|
|
297
|
+
if (limit != null) args.push('--limit', String(limit));
|
|
298
|
+
if (rpc) args.push('--rpc', rpc);
|
|
299
|
+
if (accounts) args.push('--accounts');
|
|
300
|
+
if (owners) args.push('--owners');
|
|
301
|
+
if (accountData) args.push('--account-data');
|
|
302
|
+
const r = await run(this.bin, args);
|
|
303
|
+
return JSON.parse(r.stdout);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Escape hatch: any documented command, raw. The exit code comes back
|
|
308
|
+
* instead of being classified, so a caller can implement semantics this
|
|
309
|
+
* file does not know yet — without re-implementing the spawn. Codes the
|
|
310
|
+
* typed API refuses (2/64/101/signal) are still handed back here; that
|
|
311
|
+
* is the hatch's whole point.
|
|
312
|
+
*/
|
|
313
|
+
async raw(args) {
|
|
314
|
+
return run(this.bin, args, { allowExit3: true, unclassified: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Minimal tar reader for ONE regular file named `reeflog` — enough for the
|
|
320
|
+
* release archives (`tar -czf … reeflog`, ustar/gnu, one entry). It refuses
|
|
321
|
+
* anything it does not understand rather than guessing: a bootstrap that
|
|
322
|
+
* writes an unexpected layout is worse than one that stops.
|
|
323
|
+
*/
|
|
324
|
+
function extractReeflogFromTarGz(gz) {
|
|
325
|
+
const tar = gunzipSync(gz);
|
|
326
|
+
const wanted = [];
|
|
327
|
+
for (let off = 0; off + 512 <= tar.length; off += 512) {
|
|
328
|
+
const header = tar.subarray(off, off + 512);
|
|
329
|
+
if (header.every((b) => b === 0)) break; // end-of-archive marker
|
|
330
|
+
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '');
|
|
331
|
+
const sizeField = header.subarray(124, 136).toString('utf8').replace(/\0.*$/s, '').trim();
|
|
332
|
+
const size = parseInt(sizeField || '0', 8);
|
|
333
|
+
const type = String.fromCharCode(header[156]);
|
|
334
|
+
const data = tar.subarray(off + 512, off + 512 + size);
|
|
335
|
+
if ((type === '0' || type === '\0' || type === '') && path.posix.basename(name) === 'reeflog') {
|
|
336
|
+
wanted.push(Buffer.from(data));
|
|
337
|
+
}
|
|
338
|
+
off += 512 * Math.ceil(size / 512);
|
|
339
|
+
}
|
|
340
|
+
if (wanted.length !== 1) {
|
|
341
|
+
throw new ReeflogError(
|
|
342
|
+
`release archive holds ${wanted.length} 'reeflog' binaries, expected exactly 1 — refusing to install an archive I do not understand`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return wanted[0];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The prebuilt archives the release workflow actually publishes. This list
|
|
349
|
+
// must name EXACTLY the assets in .github/workflows/release.yml — a client
|
|
350
|
+
// that maps a platform to an asset the release does not carry sends a
|
|
351
|
+
// stranger to a 404 instead of a binary. tests/bootstrap.test.mjs pins the
|
|
352
|
+
// two lists against each other.
|
|
353
|
+
export const RELEASE_ASSETS = {
|
|
354
|
+
'linux-x64': 'reeflog-linux-amd64.tar.gz',
|
|
355
|
+
'darwin-arm64': 'reeflog-macos-arm64.tar.gz',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** The bound on one HTTP leg of bootstrap: the SAME no-answer class the
|
|
359
|
+
* daemon-side webhook refuses with `timeout_ms` (src/webhook.rs,
|
|
360
|
+
* MAX_TIMEOUT_MS — "a webhook whose endpoint hangs must not hang the
|
|
361
|
+
* delivery pass") was re-opened on the client. Measured 2026-11-11 against
|
|
362
|
+
* the PUBLISHED v0.7.17 tree: an endpoint that accepts the connection and
|
|
363
|
+
* never answers left `await bootstrap()` hanging with NO verdict (45 s
|
|
364
|
+
* watchdog, research/bootstrap-hang-published-v0717-2026-11-11.json), and a
|
|
365
|
+
* self-referential 302 was re-requested 14,937 times in 12 s with none.
|
|
366
|
+
*
|
|
367
|
+
* The rule: every leg gets a verdict inside the budget. The timer is an
|
|
368
|
+
* INACTIVITY bound, not a total one — it arms at headers and resets on every
|
|
369
|
+
* received byte — so a slow link that makes progress keeps working (guard
|
|
370
|
+
* G3), while silence cannot park a stranger's install forever. Redirects
|
|
371
|
+
* follow at most MAX_REDIRECTS hops and a loop is REFUSED BY NAME, not
|
|
372
|
+
* retried into the ground. */
|
|
373
|
+
export const DEFAULT_GET_TIMEOUT_MS = 30_000;
|
|
374
|
+
export const MAX_REDIRECTS = 5;
|
|
375
|
+
|
|
376
|
+
function httpGet(url, { timeoutMs = DEFAULT_GET_TIMEOUT_MS, redirectsLeft = MAX_REDIRECTS } = {}) {
|
|
377
|
+
const mod = url.startsWith('https:') ? https : http;
|
|
378
|
+
return new Promise((resolve, reject) => {
|
|
379
|
+
let settled = false;
|
|
380
|
+
const fail = (message) => {
|
|
381
|
+
if (settled) return;
|
|
382
|
+
settled = true;
|
|
383
|
+
reject(new ReeflogError(message));
|
|
384
|
+
};
|
|
385
|
+
const req = mod.get(url, { headers: { 'user-agent': 'reeflog-client' } }, (res) => {
|
|
386
|
+
// Headers arrived: the headers-phase timer has done its job. Hand the
|
|
387
|
+
// budget to the between-bytes timer below. (Without this, the socket
|
|
388
|
+
// idle timeout re-arms on the last byte and races the quiet timer at
|
|
389
|
+
// the same deadline, then reports a headers stall that never was.)
|
|
390
|
+
req.setTimeout(0);
|
|
391
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
392
|
+
res.resume();
|
|
393
|
+
if (redirectsLeft <= 0) {
|
|
394
|
+
// The loop face, refused. A bounded hop count turning into this
|
|
395
|
+
// refusal is the whole difference between "mirror hiccup" and
|
|
396
|
+
// "14,937 re-requests with no verdict".
|
|
397
|
+
fail(`download of ${url} failed: redirect chain exceeded ${MAX_REDIRECTS} hops — refusing to follow a redirect loop (this hop: ${url})`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let next;
|
|
401
|
+
try {
|
|
402
|
+
next = new URL(res.headers.location, url).href;
|
|
403
|
+
} catch {
|
|
404
|
+
fail(`download of ${url} failed: HTTP ${res.statusCode} with unparseable redirect target ${JSON.stringify(res.headers.location)}`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
httpGet(next, { timeoutMs, redirectsLeft: redirectsLeft - 1 }).then(
|
|
408
|
+
(b) => { if (!settled) resolve(b); },
|
|
409
|
+
(e) => fail(e.message),
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (res.statusCode !== 200) {
|
|
414
|
+
res.resume();
|
|
415
|
+
fail(`download of ${url} failed: HTTP ${res.statusCode}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
// Headers arrived: the budget now runs BETWEEN bytes. Any single gap
|
|
419
|
+
// of timeoutMs with no progress is the stall; any byte defers the
|
|
420
|
+
// verdict, which keeps real slow links on the working side.
|
|
421
|
+
let quiet = null;
|
|
422
|
+
const armQuiet = () => {
|
|
423
|
+
if (quiet) clearTimeout(quiet);
|
|
424
|
+
quiet = setTimeout(() => {
|
|
425
|
+
res.destroy();
|
|
426
|
+
fail(`download of ${url} stalled: no bytes for ${timeoutMs} ms after headers — refusing to wait without a verdict (pass { timeoutMs } to bootstrap() to allow slower links)`);
|
|
427
|
+
}, timeoutMs);
|
|
428
|
+
quiet.unref?.();
|
|
429
|
+
};
|
|
430
|
+
armQuiet();
|
|
431
|
+
const chunks = [];
|
|
432
|
+
res.on('data', (d) => { chunks.push(d); armQuiet(); });
|
|
433
|
+
res.on('end', () => {
|
|
434
|
+
if (quiet) clearTimeout(quiet);
|
|
435
|
+
if (!settled) { settled = true; resolve(Buffer.concat(chunks)); }
|
|
436
|
+
});
|
|
437
|
+
res.on('error', () => fail(`download of ${url} failed: connection reset mid-body`));
|
|
438
|
+
});
|
|
439
|
+
// No verdict at all inside the budget is the defect; destroy the socket.
|
|
440
|
+
// (An un-destroyed stalled request also pins the process open — the
|
|
441
|
+
// pre-fix ladder could not even exit its own runner.)
|
|
442
|
+
req.setTimeout(timeoutMs, () => {
|
|
443
|
+
req.destroy();
|
|
444
|
+
fail(`download of ${url} stalled: no response headers within ${timeoutMs} ms — refusing to wait without a verdict (pass { timeoutMs } to bootstrap() to allow slower links)`);
|
|
445
|
+
});
|
|
446
|
+
req.on('error', (e) => fail(`download of ${url} failed: ${e.message}`));
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The version this client ships with — read from the package manifest, never
|
|
451
|
+
* hard-coded. A hard-coded version here is the pin-lag defect this repo has
|
|
452
|
+
* already measured twice (signerd v0.2.4→v0.2.6): the client would fetch
|
|
453
|
+
* an OLD release while the package says a newer one. test/bootstrap.test.mjs
|
|
454
|
+
* pins the two together. */
|
|
455
|
+
function packageVersion() {
|
|
456
|
+
try {
|
|
457
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8'));
|
|
458
|
+
if (pkg && typeof pkg.version === 'string' && /^\d+\.\d+\.\d+$/.test(pkg.version)) {
|
|
459
|
+
return `v${pkg.version}`;
|
|
460
|
+
}
|
|
461
|
+
} catch { /* fall through to the explicit version */ }
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Install the `reeflog` binary for this platform WITHOUT a Rust toolchain:
|
|
467
|
+
* take the prebuilt archive from the GitHub release, verify its bytes
|
|
468
|
+
* against the sha256 the release API stores for that asset, then extract.
|
|
469
|
+
*
|
|
470
|
+
* Trust: the same anchor as `cargo install --git` (this repository over
|
|
471
|
+
* TLS) — it does not defend against whoever can push tags here, and says
|
|
472
|
+
* so. Unsupported platforms and missing digests REFUSE; nothing is written
|
|
473
|
+
* before the digest matches. A cache hit re-hashes the installed binary
|
|
474
|
+
* against the sidecar's `binarySha256` — the return value's `digest` is the
|
|
475
|
+
* ARCHIVE hash, `binarySha256` is the hash of `bin` itself.
|
|
476
|
+
*
|
|
477
|
+
* @returns {Promise<{bin: string, asset: string, digest: string, binarySha256: string, cached: boolean}>}
|
|
478
|
+
*/
|
|
479
|
+
export async function bootstrap({
|
|
480
|
+
version = packageVersion(),
|
|
481
|
+
platform = process.platform,
|
|
482
|
+
arch = process.arch,
|
|
483
|
+
dest = path.join(os.homedir(), '.reeflog'),
|
|
484
|
+
apiBase = 'https://api.github.com',
|
|
485
|
+
downloadBase = null,
|
|
486
|
+
timeoutMs = DEFAULT_GET_TIMEOUT_MS,
|
|
487
|
+
} = {}) {
|
|
488
|
+
const asset = RELEASE_ASSETS[`${platform}-${arch}`];
|
|
489
|
+
if (!asset) {
|
|
490
|
+
throw new ReeflogError(
|
|
491
|
+
`no prebuilt reeflog binary for ${platform}-${arch} (published: ${Object.keys(RELEASE_ASSETS).join(', ')}) — install from source: \`cargo install --git https://github.com/auroraxo/reeflog --tag ${version}\``,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (!/^v\d+\.\d+\.\d+$/.test(String(version))) {
|
|
495
|
+
throw new ReeflogError(`bootstrap needs an explicit release tag like ${packageVersion() || 'v0.7.0'}, got ${JSON.stringify(version)}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const bin = path.join(dest, 'reeflog');
|
|
499
|
+
const metaFile = path.join(dest, 'install.json');
|
|
500
|
+
if (fs.existsSync(bin) && fs.existsSync(metaFile)) {
|
|
501
|
+
try {
|
|
502
|
+
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
503
|
+
if (meta.version === version && meta.asset === asset) {
|
|
504
|
+
// A cache hit is a VERIFIED claim, so it has to be verified here:
|
|
505
|
+
// re-hash the bytes on disk and compare them to the sidecar. Before
|
|
506
|
+
// this check the sidecar's version+asset alone produced
|
|
507
|
+
// `{ cached: true, digest: <archive sha256> }` for ANY file sitting
|
|
508
|
+
// at `bin` — replaced, truncated, or corrupted — and `digest` names
|
|
509
|
+
// the ARCHIVE, never the binary (measured 2026-11-07 against the
|
|
510
|
+
// published v0.7.0 client: tampered binary reported as verified,
|
|
511
|
+
// claimed f6b36c53… / actual 6b9fd8b7…).
|
|
512
|
+
// A sidecar without `binarySha256` (every install made before this
|
|
513
|
+
// fix) proves nothing about the bytes: fall through, re-fetch once,
|
|
514
|
+
// upgrade the sidecar, cache again.
|
|
515
|
+
if (typeof meta.binarySha256 === 'string' && /^[0-9a-f]{64}$/.test(meta.binarySha256)) {
|
|
516
|
+
const onDisk = createHash('sha256').update(fs.readFileSync(bin)).digest('hex');
|
|
517
|
+
if (onDisk === meta.binarySha256) {
|
|
518
|
+
return { bin, asset, digest: meta.sha256, binarySha256: onDisk, cached: true };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch { /* fall through to a fresh install */ }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const releaseUrl = `${apiBase}/repos/auroraxo/reeflog/releases/tags/${version}`;
|
|
526
|
+
const rawRelease = (await httpGet(releaseUrl, { timeoutMs })).toString('utf8');
|
|
527
|
+
// The API leg can answer 200 with a body that is not a release: a captive
|
|
528
|
+
// portal's login HTML (measured 2026-11-11 against the published v0.7.18
|
|
529
|
+
// tree: JSON.parse threw SyntaxError straight out of bootstrap(), a crash
|
|
530
|
+
// no ReeflogError catch ever saw), or a rate-limit/proxy JSON whose own
|
|
531
|
+
// `message` was then discarded in favour of "release has no asset", a
|
|
532
|
+
// verdict that sends a stranger to a release page that is fine
|
|
533
|
+
// (research/captive-portal-2026-11-11.json). A body that is not a release
|
|
534
|
+
// object must refuse WITH the body's own words — never with an engine
|
|
535
|
+
// crash, never with a cause the endpoint did not give.
|
|
536
|
+
let release;
|
|
537
|
+
try {
|
|
538
|
+
release = JSON.parse(rawRelease);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
const looksHtml = /^\s*</.test(rawRelease);
|
|
541
|
+
throw new ReeflogError(
|
|
542
|
+
`the release API at ${releaseUrl} did not answer JSON — `
|
|
543
|
+
+ (looksHtml
|
|
544
|
+
? 'it answered with HTML (a captive portal or an intercepting proxy fits this shape)'
|
|
545
|
+
: `JSON.parse failed: ${e.message}`)
|
|
546
|
+
+ ' — refusing to install from a body I do not understand. Behind a captive portal, authenticate in the browser first; for a mirror or a different host pass { apiBase }.',
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (release === null || typeof release !== 'object' || Array.isArray(release) || !Array.isArray(release.assets)) {
|
|
550
|
+
const words = release && typeof release.message === 'string'
|
|
551
|
+
? `its own message: ${release.message}`
|
|
552
|
+
: `body: ${JSON.stringify(release)?.slice(0, 300) ?? String(release)}`;
|
|
553
|
+
throw new ReeflogError(
|
|
554
|
+
`the release API at ${releaseUrl} answered without a release object (${words}) — refusing to install; nothing was written. `
|
|
555
|
+
+ 'If this is a rate limit or a proxy, wait or bypass it; for a different host pass { apiBase }.',
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const entry = (release.assets || []).find((a) => a.name === asset);
|
|
559
|
+
if (!entry) {
|
|
560
|
+
throw new ReeflogError(`release ${version} has no asset named ${asset} — nothing to install`);
|
|
561
|
+
}
|
|
562
|
+
const expected = (entry.digest || '').replace(/^sha256:/i, '');
|
|
563
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
564
|
+
throw new ReeflogError(
|
|
565
|
+
`the release API reports no sha256 digest for ${asset} — refusing to install bytes I cannot verify`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const url = downloadBase ? `${downloadBase}/${asset}` : entry.browser_download_url;
|
|
570
|
+
const gz = await httpGet(url, { timeoutMs });
|
|
571
|
+
const actual = createHash('sha256').update(gz).digest('hex');
|
|
572
|
+
if (actual !== expected) {
|
|
573
|
+
throw new ReeflogError(
|
|
574
|
+
`sha256 mismatch for ${asset}: release says ${expected}, download is ${actual} — refusing to install`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const executable = extractReeflogFromTarGz(gz);
|
|
579
|
+
const binarySha256 = createHash('sha256').update(executable).digest('hex');
|
|
580
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
581
|
+
const tmp = path.join(dest, `.reeflog-${process.pid}.tmp`);
|
|
582
|
+
fs.writeFileSync(tmp, executable, { mode: 0o755 });
|
|
583
|
+
fs.renameSync(tmp, bin); // atomic: a crash leaves the old binary or none
|
|
584
|
+
// `sha256` is the ARCHIVE digest (what the release API stores);
|
|
585
|
+
// `binarySha256` is the EXECUTABLE's own hash — the only value the cache
|
|
586
|
+
// may be checked against, and the one a caller can check `bin` with.
|
|
587
|
+
fs.writeFileSync(metaFile, `${JSON.stringify({ version, asset, sha256: actual, binarySha256, installedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
588
|
+
|
|
589
|
+
return { bin, asset, digest: actual, binarySha256, cached: false };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Convenience constructor: `const box = reeflog({ db: 'reeflog.db' })`. */
|
|
593
|
+
export function reeflog(opts) {
|
|
594
|
+
return new Reeflog(opts);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export default reeflog;
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reeflog-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.21",
|
|
4
|
+
"description": "Node client for reeflog, the durable local mailbox for Solana events: cursor-based event delivery, retractions, health and repair.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/auroraxo/reeflog.git"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"solana",
|
|
25
|
+
"events",
|
|
26
|
+
"journal",
|
|
27
|
+
"cursor",
|
|
28
|
+
"mailbox"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"reeflog-client": "github:auroraxo/reeflog#v0.7.20"
|
|
35
|
+
}
|
|
36
|
+
}
|