Skip to content

Commit 569f034

Browse files
author
web3blind
committed
Add Viz Magic archive mirror service
1 parent 946146f commit 569f034

4 files changed

Lines changed: 344 additions & 4 deletions

File tree

app/js/config.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ var VizMagicConfig = (function() {
1313

1414
/**
1515
* Optional read-only archive mirrors for old block fetches.
16-
* Keep empty until a public mirror is verified for CORS and block-shape parity.
16+
* VIZ RPC remains primary; mirrors are only used as fallback.
1717
* Supported endpoint pattern: URL may contain {block}, for example
1818
* https://example.invalid/viz/block/{block}.json
1919
*/
20-
var HISTORY_ARCHIVE_MIRRORS = [];
20+
var HISTORY_ARCHIVE_MIRRORS = [
21+
{
22+
url: 'https://vizmagic.web3blind.xyz/archive-mirror/{block}.json',
23+
timeoutMs: 8000
24+
}
25+
];
2126

2227
/** Protocol identifiers registered on VIZ chain */
2328
var PROTOCOLS = {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Archive mirror service regression tests.
3+
*/
4+
'use strict';
5+
6+
var assert = require('assert');
7+
var http = require('http');
8+
var fs = require('fs');
9+
var os = require('os');
10+
var path = require('path');
11+
var mirror = require('../tools/archive-mirror-server');
12+
13+
var TEST_BLOCK = {
14+
previous: '0000000000000000000000000000000000000000',
15+
block_id: '0012d68700000000000000000000000000000000',
16+
timestamp: '2026-06-20T00:00:00',
17+
transactions: []
18+
};
19+
20+
function test(name, fn) {
21+
Promise.resolve().then(fn).then(function() {
22+
console.log('PASS ' + name);
23+
}).catch(function(err) {
24+
console.error('FAIL ' + name + ': ' + (err && err.stack || err));
25+
process.exitCode = 1;
26+
});
27+
}
28+
29+
function listen(server) {
30+
return new Promise(function(resolve) {
31+
server.listen(0, '127.0.0.1', function() {
32+
resolve(server.address().port);
33+
});
34+
});
35+
}
36+
37+
function close(server) {
38+
return new Promise(function(resolve) { server.close(resolve); });
39+
}
40+
41+
function getJson(port, urlPath) {
42+
return new Promise(function(resolve, reject) {
43+
http.get({ host: '127.0.0.1', port: port, path: urlPath }, function(res) {
44+
var chunks = '';
45+
res.setEncoding('utf8');
46+
res.on('data', function(chunk) { chunks += chunk; });
47+
res.on('end', function() {
48+
try {
49+
resolve({ status: res.statusCode, body: JSON.parse(chunks), headers: res.headers });
50+
} catch (err) {
51+
reject(err);
52+
}
53+
});
54+
}).on('error', reject);
55+
});
56+
}
57+
58+
function startRpcServer() {
59+
var calls = 0;
60+
var server = http.createServer(function(req, res) {
61+
calls += 1;
62+
var body = '';
63+
req.on('data', function(chunk) { body += chunk; });
64+
req.on('end', function() {
65+
var payload = JSON.parse(body || '{}');
66+
assert.strictEqual(payload.method, 'call');
67+
assert.strictEqual(payload.params[1], 'get_block');
68+
res.writeHead(200, { 'Content-Type': 'application/json' });
69+
res.end(JSON.stringify({ jsonrpc: '2.0', result: TEST_BLOCK, id: payload.id }));
70+
});
71+
});
72+
return listen(server).then(function(port) {
73+
return { server: server, url: 'http://127.0.0.1:' + port + '/', calls: function() { return calls; } };
74+
});
75+
}
76+
77+
test('extracts supported public and proxied block paths', function() {
78+
assert.strictEqual(mirror.extractBlockNum('/123.json'), 123);
79+
assert.strictEqual(mirror.extractBlockNum('/archive-mirror/123.json'), 123);
80+
assert.strictEqual(mirror.extractBlockNum('/v1/block/123.json'), 123);
81+
assert.strictEqual(mirror.extractBlockNum('/archive-mirror/v1/block/123.json'), 123);
82+
assert.strictEqual(mirror.extractBlockNum('/archive-mirror/nope.json'), 0);
83+
});
84+
85+
test('serves health, fetches block once, then uses disk cache', async function() {
86+
var rpc = await startRpcServer();
87+
var cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'viz-mirror-test-'));
88+
var server = mirror.createServer({ cacheDir: cacheDir, nodes: [rpc.url], timeoutMs: 2000 });
89+
var port = await listen(server);
90+
try {
91+
var health = await getJson(port, '/archive-mirror/health');
92+
assert.strictEqual(health.status, 200);
93+
assert.strictEqual(health.body.ok, true);
94+
95+
var first = await getJson(port, '/archive-mirror/1234567.json');
96+
assert.strictEqual(first.status, 200);
97+
assert.strictEqual(first.body.source, 'rpc');
98+
assert.strictEqual(first.body.block.block_id, TEST_BLOCK.block_id);
99+
assert.strictEqual(rpc.calls(), 1);
100+
101+
var second = await getJson(port, '/v1/block/1234567.json');
102+
assert.strictEqual(second.status, 200);
103+
assert.strictEqual(second.body.source, 'cache');
104+
assert.strictEqual(rpc.calls(), 1);
105+
} finally {
106+
await close(server);
107+
await close(rpc.server);
108+
fs.rmSync(cacheDir, { recursive: true, force: true });
109+
}
110+
});
111+
112+
process.on('beforeExit', function() {
113+
if (process.exitCode) process.exit(process.exitCode);
114+
});

tests/history-source-regressions.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ test('history source wraps VIZ block/account access', function () {
5151
assert.ok(/getCapabilities/.test(historySourceJs), 'capabilities API missing');
5252
});
5353

54-
test('archive mirror config is explicit and disabled until verified', function () {
54+
test('archive mirror config is explicit and points at production nginx path', function () {
5555
assert.ok(/HISTORY_ARCHIVE_MIRRORS/.test(configJs), 'archive mirror config missing');
56-
assert.ok(/var HISTORY_ARCHIVE_MIRRORS = \[\]/.test(configJs), 'mirrors should default to empty until verified for CORS/parity');
56+
assert.ok(/vizmagic\.web3blind\.xyz\/archive-mirror\/\{block\}\.json/.test(configJs), 'production archive mirror URL missing');
57+
assert.ok(/timeoutMs:\s*8000/.test(configJs), 'mirror timeout should be explicit');
5758
assert.ok(/\{block\}/.test(configJs), 'mirror URL pattern should document block placeholder');
5859
});
5960

tools/archive-mirror-server.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Viz Magic archive mirror service.
4+
*
5+
* Lightweight read-only HTTP service for HistorySource archive mirror requests.
6+
* It keeps RAM low by fetching one block at a time from public VIZ RPC nodes and
7+
* caching successful block JSON on disk. It never accepts private keys or writes
8+
* gameplay state.
9+
*
10+
* Routes:
11+
* GET /health
12+
* GET /archive-mirror/health
13+
* GET /:block.json
14+
* GET /archive-mirror/:block.json
15+
* GET /v1/block/:block.json
16+
* GET /archive-mirror/v1/block/:block.json
17+
*/
18+
'use strict';
19+
20+
var http = require('http');
21+
var fs = require('fs');
22+
var path = require('path');
23+
24+
var DEFAULT_NODES = [
25+
'https://api.viz.world/',
26+
'https://node.viz.cx/'
27+
];
28+
29+
function parseList(value, fallback) {
30+
if (!value) return fallback.slice();
31+
var list = String(value).split(',').map(function(item) { return item.trim(); }).filter(Boolean);
32+
return list.length ? list : fallback.slice();
33+
}
34+
35+
function json(res, status, payload, extraHeaders) {
36+
var headers = {
37+
'Content-Type': 'application/json; charset=utf-8',
38+
'Access-Control-Allow-Origin': '*',
39+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
40+
'Access-Control-Allow-Headers': 'content-type',
41+
'Cache-Control': status === 200 ? 'public, max-age=31536000, immutable' : 'no-store'
42+
};
43+
Object.keys(extraHeaders || {}).forEach(function(key) { headers[key] = extraHeaders[key]; });
44+
res.writeHead(status, headers);
45+
res.end(JSON.stringify(payload));
46+
}
47+
48+
function safeBlockNum(raw) {
49+
var n = Number(raw);
50+
if (!isFinite(n) || Math.floor(n) !== n || n <= 0) return 0;
51+
if (n > 2147483647) return 0;
52+
return n;
53+
}
54+
55+
function extractBlockNum(urlPath) {
56+
var clean = String(urlPath || '').split('?')[0].replace(/\/+/g, '/');
57+
var patterns = [
58+
/^\/(\d+)\.json$/,
59+
/^\/archive-mirror\/(\d+)\.json$/,
60+
/^\/v1\/block\/(\d+)\.json$/,
61+
/^\/archive-mirror\/v1\/block\/(\d+)\.json$/
62+
];
63+
for (var i = 0; i < patterns.length; i += 1) {
64+
var m = clean.match(patterns[i]);
65+
if (m) return safeBlockNum(m[1]);
66+
}
67+
return 0;
68+
}
69+
70+
function ensureDir(dir) {
71+
fs.mkdirSync(dir, { recursive: true });
72+
}
73+
74+
function blockCachePath(cacheDir, blockNum) {
75+
return path.join(cacheDir, String(blockNum) + '.json');
76+
}
77+
78+
function readCachedBlock(cacheDir, blockNum) {
79+
var file = blockCachePath(cacheDir, blockNum);
80+
try {
81+
var parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
82+
if (parsed && parsed.block && parsed.block.transactions) return parsed.block;
83+
} catch (err) {}
84+
return null;
85+
}
86+
87+
function writeCachedBlock(cacheDir, blockNum, block) {
88+
try {
89+
ensureDir(cacheDir);
90+
var tmp = blockCachePath(cacheDir, blockNum) + '.tmp-' + process.pid;
91+
fs.writeFileSync(tmp, JSON.stringify({ block: block }), 'utf8');
92+
fs.renameSync(tmp, blockCachePath(cacheDir, blockNum));
93+
} catch (err) {
94+
// Cache write failure should not break reads.
95+
}
96+
}
97+
98+
function normalizeRpcUrl(url) {
99+
url = String(url || '').trim();
100+
return url || '';
101+
}
102+
103+
function rpcFetchBlock(nodeUrl, blockNum, timeoutMs) {
104+
return new Promise(function(resolve, reject) {
105+
var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
106+
var timer = setTimeout(function() {
107+
if (controller) controller.abort();
108+
}, timeoutMs || 8000);
109+
var body = JSON.stringify({
110+
jsonrpc: '2.0',
111+
method: 'call',
112+
params: ['database_api', 'get_block', [blockNum]],
113+
id: 1
114+
});
115+
fetch(nodeUrl, {
116+
method: 'POST',
117+
headers: { 'Content-Type': 'application/json' },
118+
body: body,
119+
signal: controller ? controller.signal : undefined
120+
}).then(function(resp) {
121+
if (!resp.ok) throw new Error('HTTP ' + resp.status);
122+
return resp.json();
123+
}).then(function(payload) {
124+
clearTimeout(timer);
125+
var block = payload && (payload.result || (payload.data && payload.data.block) || payload.block);
126+
if (!block || !block.transactions) throw new Error('empty block');
127+
resolve(block);
128+
}).catch(function(err) {
129+
clearTimeout(timer);
130+
reject(err);
131+
});
132+
});
133+
}
134+
135+
async function fetchBlockFromNodes(nodes, blockNum, timeoutMs) {
136+
var lastErr = null;
137+
for (var i = 0; i < nodes.length; i += 1) {
138+
var node = normalizeRpcUrl(nodes[i]);
139+
if (!node) continue;
140+
try {
141+
var block = await rpcFetchBlock(node, blockNum, timeoutMs);
142+
return { block: block, node: node };
143+
} catch (err) {
144+
lastErr = err;
145+
}
146+
}
147+
throw lastErr || new Error('no nodes configured');
148+
}
149+
150+
function createServer(options) {
151+
options = options || {};
152+
var startedAt = Date.now();
153+
var cacheDir = options.cacheDir || process.env.ARCHIVE_MIRROR_CACHE_DIR || path.join(process.cwd(), 'data', 'archive-mirror-cache');
154+
var nodes = options.nodes || parseList(process.env.ARCHIVE_MIRROR_NODES, DEFAULT_NODES);
155+
var timeoutMs = Number(options.timeoutMs || process.env.ARCHIVE_MIRROR_TIMEOUT_MS || 8000);
156+
ensureDir(cacheDir);
157+
158+
return http.createServer(async function(req, res) {
159+
res.setHeader('Access-Control-Allow-Origin', '*');
160+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
161+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
162+
if (req.method === 'OPTIONS') {
163+
res.writeHead(204);
164+
res.end();
165+
return;
166+
}
167+
if (req.method !== 'GET') {
168+
json(res, 405, { error: 'method_not_allowed' }, { Allow: 'GET, OPTIONS' });
169+
return;
170+
}
171+
172+
var cleanPath = String(req.url || '').split('?')[0].replace(/\/+/g, '/');
173+
if (cleanPath === '/health' || cleanPath === '/archive-mirror/health') {
174+
json(res, 200, {
175+
ok: true,
176+
service: 'viz-magic-archive-mirror',
177+
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
178+
cacheDir: cacheDir,
179+
nodes: nodes.length
180+
}, { 'Cache-Control': 'no-store' });
181+
return;
182+
}
183+
184+
var blockNum = extractBlockNum(req.url);
185+
if (!blockNum) {
186+
json(res, 404, { error: 'not_found' }, { 'Cache-Control': 'no-store' });
187+
return;
188+
}
189+
190+
var cached = readCachedBlock(cacheDir, blockNum);
191+
if (cached) {
192+
json(res, 200, { block: cached, source: 'cache' });
193+
return;
194+
}
195+
196+
try {
197+
var fetched = await fetchBlockFromNodes(nodes, blockNum, timeoutMs);
198+
writeCachedBlock(cacheDir, blockNum, fetched.block);
199+
json(res, 200, { block: fetched.block, source: 'rpc', node: fetched.node });
200+
} catch (err) {
201+
json(res, 502, { error: 'block_unavailable', message: err && err.message || String(err) }, { 'Cache-Control': 'no-store' });
202+
}
203+
});
204+
}
205+
206+
if (require.main === module) {
207+
var port = Number(process.env.PORT || process.env.ARCHIVE_MIRROR_PORT || 3007);
208+
var host = process.env.HOST || '127.0.0.1';
209+
var server = createServer();
210+
server.listen(port, host, function() {
211+
console.log('viz-magic archive mirror listening on http://' + host + ':' + port);
212+
});
213+
}
214+
215+
module.exports = {
216+
createServer: createServer,
217+
extractBlockNum: extractBlockNum,
218+
safeBlockNum: safeBlockNum,
219+
fetchBlockFromNodes: fetchBlockFromNodes
220+
};

0 commit comments

Comments
 (0)