#!/usr/bin/env node // A stand-in for the Igneum Miner engine, for working on app/igneum-app/ui without touching a running miner. // Serves the UI files at /t/mock/ (the same path shape as the engine) and replays the recorded API responses in // recorded/ with every timestamp shifted to now. POSTs are logged and answered {ok:true}; a few (pause, resume, // settings) are applied to the in-memory state so the dashboard reacts. Nothing here reaches the real engine. // // node tools/ui-mock/server.mjs [port] default 4310 // open http://127.0.0.1:4310/t/mock/?scenario=live // // Scenarios (set with ?scenario= on the page URL, or GET /mock/scenario?set=): // live the recorded state as it was (Apple M5 Max mining, node synced) // syncing node syncing, 41,000 headers, blocks climbing at a steady rate (the ETA) // nodedown node failed and restarting, miner waiting // nocards no GPU found (cards screen with ?screen=cards, and the dashboard) // integrated one integrated card only, off by default // clock clock skew that blocks mining // paused miner paused // biglog 12,000 log lines with errors and program swaps (virtualised list) // fresh setup not done (welcome, cards, address screens) // job a remote job running // firstwait miner-ui-5: mining, no block yet (the Poisson count-up), the ladder's first rung // firstblock miner-ui-5: the first block a minute ago (the block card), the key under the dust line // ladder miner-ui-5: every rung walked (vote, signature, full window, rank 3, streak, a paid shard) // GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked); // POST /api/card accepts the block card's PNG and answers a path. // The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised. import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const uiDir = path.resolve(here, '../../app/igneum-app/ui'); const coin = path.resolve(here, '../../brand/igneum-coin-1024.png'); const port = parseInt(process.argv[2] || '4310', 10); const PREFIX = '/t/mock'; // IGNEUM_MOCK_STATE= replays a state captured from a real machine (relay/playbooks/app-state-grab.ps1) instead of // the recorded one; IGNEUM_MOCK_LIVE= serves that machine's /api/live reply as it was (timestamps shifted to now) const recState = JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_STATE || path.join(here, 'recorded/state.json'), 'utf8')); const recLive = process.env.IGNEUM_MOCK_LIVE ? JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_LIVE, 'utf8')) : null; const recLog = JSON.parse(fs.readFileSync(path.join(here, 'recorded/log.json'), 'utf8')).lines; const t0 = Date.now() / 1000; // server start const shift = t0 - recState.now; // recorded clock -> live clock let scenario = 'live'; let paused = false; let seq = 0; let log = []; const posts = []; function clone(v) { return JSON.parse(JSON.stringify(v)); } function now() { return Date.now() / 1000; } function seedLog() { seq = 0; log = []; for (const l of recLog) log.push({ seq: ++seq, t: l.t + shift, src: l.src, err: l.err, text: l.text }); if (scenario === 'biglog') { const base = log[log.length - 1].t - 3600 * 2; const srcs = ['node', 'node', 'node', 'miner1', 'watch', 'app']; for (let i = 0; i < 12000; i++) { const src = srcs[i % srcs.length]; const t = base + i * 0.6; let text, err = false; if (i % 1500 === 1499) { src === 'app'; text = '[build] hourly program changed: new program ' + i; } else if (i % 977 === 500) { err = true; text = 'worker: kernel launch failed (code 702) on identity mac-d937c69d-1; restarting in 5 s'; } else if (src === 'node') text = '2026-10-04 18:00:00.000+01:00 [INFO ] PoW accepted ' + (i * 2654435761 >>> 0).toString(16).padStart(8, '0') + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (30000 + i) + ', nonce 0x' + (i * 97).toString(16) + ')'; else if (src === 'miner1') text = (t).toFixed(3) + ' ACCEPTED block nonce=0x' + (i * 31).toString(16) + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)'; else if (src === 'watch') text = t.toFixed(3) + ' node1 blocks=' + (30000 + i) + ' headers=' + (30000 + i) + ' daa=' + (30000 + i) + ' tips=1 peers=4 synced=true'; else text = 'status: accepted ' + (5000 + i / 6 | 0) + ' blocks, 17.0 MH/s, mining | node ' + (30000 + i) + ' blocks, 4 peers, synced'; log.push({ seq: ++seq, t, src: i % 1500 === 1499 ? 'app' : src, err, text }); } log.sort((a, b) => a.t - b.t); log.forEach((l, i) => { l.seq = i + 1; }); seq = log.length; } } // a trickle of new lines so the drawer has something to follow let tick = 0; function growLog() { const t = now(); tick++; const h = (t * 1000 | 0).toString(16); log.push({ seq: ++seq, t, src: 'node', err: false, text: new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 23) + ' [INFO ] PoW accepted ' + h + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (39571 + tick) + ')' }); if (tick % 4 === 0) log.push({ seq: ++seq, t, src: 'miner1', err: false, text: t.toFixed(3) + ' ACCEPTED block nonce=0x' + h + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)' }); if (tick % 10 === 0) log.push({ seq: ++seq, t, src: 'watch', err: false, text: t.toFixed(3) + ' node1 blocks=' + (39571 + tick) + ' headers=' + (39571 + tick) + ' tips=1 peers=4 synced=true' }); if (tick % 45 === 0) log.push({ seq: ++seq, t, src: 'app', err: true, text: '[error] mock: a sample error line so "last error" has somewhere to jump' }); if (tick % 120 === 60) log.push({ seq: ++seq, t, src: 'app', err: false, text: '[build] hourly program changed: new program (mock swap)' }); while (log.length > 40000) log.shift(); } setInterval(growLog, 1000); // /api/live for the embedded DAG scene (site/live-dag.js): a synthetic chain at one block a second, four miner lanes // ("you" is this machine), parents into the last two seconds, the selected chain through "you" and "miner-2", blue // and red colouring, proofs filling two blocks in three after 20 s, a checkpoint band every 30 s locked after 10 s. const LIVE_T0 = Math.floor(Date.now() / 1000) - 400; function liveNow(windowS) { const t = Math.floor(now()), from = t - Math.min(300, Math.max(30, windowS)) - 25, blocks = []; const lanes = ['you', 'miner-2', 'miner-3', 'miner-4']; let prev = []; for (let s = from; s <= t - 2; s++) { const seed = ((s % 100000) * 7919 + ((s >> 3) % 97) * 131) % 1000; const miner = lanes[seed % 4], hash = 'b' + s.toString(16).padStart(10, '0'); const chain = miner === 'you' || miner === 'miner-2' ? (seed % 7 !== 3) : (seed % 5 === 0); const color = seed % 11 === 0 ? 'red' : (s < t - 4 ? 'blue' : 'pending'); const proven = color === 'blue' && s < t - 20 && seed % 3 !== 0; const shards = proven ? [{ state: 'paid' }] : color === 'blue' && s < t - 8 ? [{ state: 'proving' }] : []; const parents = prev.slice(-2).map(b => b.hash); const locked = (s - LIVE_T0) % 30 === 0 && s < t - 10; const b = { hash, ts: s * 1000, blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), parents, chain, color, miner, locked, final: s < t - 40, shards, proven }; blocks.push(b); prev.push(b); if (prev.length > 3) prev.shift(); } const checkpoints = []; for (let s = from; s <= t - 2; s++) if ((s - LIVE_T0) % 30 === 0) checkpoints.push({ index: Math.floor((s - LIVE_T0) / 30), hash: 'c' + s.toString(16), blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), state: s < t - 10 ? 'locked' : 'pending', fraction_total: s < t - 10 ? 0.71 : 0.44 }); return { ok: true, now: t, state: { stale: false, age_s: 1, height: 198000 + (t - LIVE_T0) }, blocks, finality: { checkpoints } }; } // miner-ui-5: this machine's ladder record (state.ladder, src/ladder.rs) per scenario function ladderNow(t) { const base = { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: t0 - 2800, first_mining_at: t0 - 2700 }; const hash = '9f3a7c1e5d2b4a6f8e0c1d3b5a7f9e2c4d6b8a0f1e3c5d7b9a2f4e6c8d0b1a3c21e'; if (scenario === 'firstblock') return { ...base, first_block_at: t - 70, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 266454, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, blocks: [{ hash, daa: 266454, nonce: '267dd27200a91c80', at: t - 70, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 1 }], votes_signed: 2, last_vote_index: 8496, last_vote_at: t - 20, milestone: { kind: 'first', count: 1, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266454, at: t - 70 } }; if (scenario === 'ladder') return { ...base, first_block_at: t - 7300, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 259200, days_mined_30: 1, blocks_30d: 61, blocks_today: 61, votes_signed: 240, last_vote_index: 8495, last_vote_at: t - 12, last_signed_locked: 8495, signed_locked_count: 238, streak_s: 7100, shards: [{ block: 160390, shard: 3, wei: '1150000000000000000', at: t - 400 }], milestone: { kind: 'hundred', count: 100, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266400, at: t - 50 } }; return base; } // miner-ui-5: GET /api/ladder, in src/chainfacts.rs's shape: the devnet's finality params (api/live, 7 Oct 2026), // four keys relayed through the observer, this machine's key 8fafda27 ranked per scenario function ladderReply(t) { const mine = scenario === 'firstblock' ? { id: '8fafda27', blocks: 1, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4 } : scenario === 'ladder' ? { id: '8fafda27', blocks: 61, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 3 } : scenario === 'firstwait' ? null : { id: '8fafda27', blocks: 251, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 2 }; return { ok: true, source: 'observer', read_at: t, cached_age_s: 0, rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: 'igneum.network/api/live (the observer\'s copy of the node\'s reply)' }, params: { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 }, checkpoint_index: 8495, daa_score: 266454, latest_locked_index: 8495, next_index: 8496, finality_active: true, total_weight: 6000, active_weight: 5900.5, voters: 14, keys_listed: 4, keys_cap: 64, network: { hashrate_hps: 764944722, miner_ign_per_block: 4.88044084, ign_per_block: 6.10055105, ramp_factor: 0.192519, daa: 266454, blocks_per_day_measured: 92280, bps: 1, stale: false }, mine: mine ? [mine] : [], best: mine }; } function stateNow() { const s = clone(recState); const t = now(); s.now = t; s.ladder = ladderNow(t); s.installed_at = t0 - 3000; if (scenario === 'firstwait') { s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.mining.found = []; s.finality = { ...s.finality, last_lock: 0, votes: 0 }; } if (scenario === 'firstblock') { s.mining.accepted_total = 1; s.mining.accepted_session = 1; s.mining.cards[0].accepted = 1; } s.uptime_s = recState.uptime_s + Math.floor(t - t0); s.mining.found = recState.mining.found.map(x => x + shift).filter(x => t - x < 3600); s.events = recState.events.map(e => ({ ...e, t: e.t + shift })); s.finality.last_lock_at += shift; s.finality.age_s = t - s.finality.last_lock_at; s.update.checked_at += shift; s.clock.checked_at += shift; s.jobs.checked_at += shift; s.program.eta_s = Math.max(0, Math.round(3600 - ((t - t0) % 3600))); s.node.blocks = recState.node.blocks + Math.floor(t - t0); s.node.headers = s.node.blocks; s.node.daa = s.node.blocks; s.node.last_reading_age_s = (t - t0) % 10; // a captured state (IGNEUM_MOCK_STATE) is replayed as it was: the machine's own name, cards, rate and numbers if (process.env.IGNEUM_MOCK_STATE) { s.node.blocks = recState.node.blocks; s.node.headers = recState.node.headers; s.node.daa = recState.node.daa; return s; } // a live-looking hash rate const wob = Math.sin((t - t0) / 7) * 1.4; s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total; // a fresh block every ~9 s on average (none before the first block) const extra = []; if (scenario !== 'firstwait' && scenario !== 'firstblock') for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4); if (scenario === 'firstwait') { s.mining.found = []; s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.events = s.events.filter(e => e.kind !== 'block'); } if (scenario === 'firstblock') { s.mining.found = [t - 70]; s.events = [{ kind: 'block', t: t - 70, text: 'first block: found by ' + s.mining.cards[0].name + ' (block card)' }, { kind: 'block', t: t - 70, text: 'block accepted by the node (' + s.mining.cards[0].name + ')' }].concat(s.events.filter(e => e.kind !== 'block')).slice(0, 40); } if (scenario === 'ladder') { s.settings.prove = true; s.proving = { ...s.proving, enabled: true, available: true, status: 'idle', paid: 1, paid_wei: '1150000000000000000', last_paid: 'block 160390 shard 3' }; s.events = [{ kind: 'proving', t: t - 400, text: 'block 160390 shard 3 paid 1.15 IGN' }, { kind: 'block', t: t - 50, text: 'block 100 of this machine: found by ' + s.mining.cards[0].name + ' (block card)' }].concat(s.events).slice(0, 40); } s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b); s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length; if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40); s.display_name = 'studio-mac-pro-with-a-long-name'; s.host = s.display_name; if (paused || scenario === 'paused') { s.mining.paused = true; s.mining.state = 'paused'; s.mining.cards[0].state = 'off'; s.mining.hash_total = 0; s.mining.cards[0].hash_now = 0; } if (scenario === 'syncing') { const rate = 38; // blocks per second s.node.state = 'syncing'; s.node.synced = false; s.node.headers = 412000; s.node.blocks = Math.min(412000, 30000 + Math.floor((t - t0) * rate)); s.node.daa = s.node.blocks; s.node.message = 'downloading blocks'; s.node.peers = 2; s.node.tips = 0; s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1; s.program.message = ''; s.finality = { age_s: 0, last_lock: 0, last_lock_at: 0, message: 'Finality starts after the node is synced.', votes: 0 }; } if (scenario === 'nodedown') { s.node.state = 'restarting'; s.node.synced = false; s.node.restart_in_s = 7 - Math.floor(t - t0) % 8; s.node.restarts = 3; s.node.peers = 0; s.node.message = 'the node exited (code 101)'; s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1; s.events = [{ kind: 'error', t: t - 12, text: 'node exited with code 101; restarting in 8 s' }].concat(s.events).slice(0, 40); } if (scenario === 'nocards') { s.mining.cards = []; s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = []; s.detect_message = 'No Metal, CUDA or OpenCL device answered.'; s.detecting = false; } if (scenario === 'integrated') { s.mining.cards = [{ ...recState.mining.cards[0], key: 'intel::Intel Iris Xe', name: 'Intel Iris Xe Graphics', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '96 EUs, shared memory', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default, around 0.3 MH/s and it slows the desktop', identities: 1, prepared: false }]; s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = []; } if (scenario === 'clock') { s.clock = { ...s.clock, severity: 'block', skew_s: -412, message: 'This machine\'s clock is 6 min 52 s behind the network. Blocks it finds are rejected until the clock is right.', hint: 'System Settings > General > Date & Time: turn on "Set time and date automatically"' }; s.mining.state = 'waiting'; s.mining.hash_total = 0; } if (scenario === 'fresh') { s.setup_done = false; s.phase = 'welcome'; s.address = { display: '', key_saved: false, source: '', value: '', wallet_file: '' }; } if (scenario === 'job') { s.jobs = { ...s.jobs, active: true, id: 'job-2026-10-04-17', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', started_at: t - 400, results: ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], message: '' }; } if (scenario === 'nvidia') { s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 }, { ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 }, { ...recState.mining.cards[0], key: 'intel::Intel UHD 770', name: 'Intel UHD Graphics 770', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '32 EUs', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default', identities: 1 }]; s.mining.hash_total = 510.6; s.display_name = 'rig-01'; } return s; } const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; function send(res, code, type, body, cache) { res.writeHead(code, { 'Content-Type': type, 'Cache-Control': cache ? 'public, max-age=86400' : 'no-store', 'Content-Length': Buffer.byteLength(body) }); res.end(body); } function json(res, v) { send(res, 200, 'application/json; charset=utf-8', JSON.stringify(v)); } seedLog(); http.createServer((req, res) => { const u = new URL(req.url, 'http://127.0.0.1'); if (u.pathname === '/mock/scenario') { if (u.searchParams.get('set')) { scenario = u.searchParams.get('set'); paused = false; seedLog(); } return json(res, { scenario, lines: log.length, posts: posts.slice(-20) }); } if (u.pathname === '/mock/posts') return json(res, posts); if (!u.pathname.startsWith(PREFIX)) return send(res, 404, 'text/plain', 'not found'); let rest = u.pathname.slice(PREFIX.length) || '/'; if (rest === '/' || rest === '/index.html') { if (u.searchParams.get('scenario') && u.searchParams.get('scenario') !== scenario) { scenario = u.searchParams.get('scenario'); paused = false; seedLog(); } return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html'))); } if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow()); if (rest === '/api/live' && req.method === 'GET') { if (recLive && recLive.blocks) { // replayed with its newest block 3 s behind now, so the module's window shows it as it was captured const newest = Math.max(...recLive.blocks.map(b => b.ts || 0)), dt = Date.now() - 3000 - newest; const d = JSON.parse(JSON.stringify(recLive)); d.blocks.forEach(b => { b.ts = (b.ts || newest) + dt; if (!b.miner) b.miner = 'unknown'; }); d.now = Math.floor(Date.now() / 1000); return json(res, d); } return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10))); } if (rest === '/api/ladder' && req.method === 'GET') return json(res, ladderReply(now())); if (rest === '/api/log' && req.method === 'GET') { const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10)); let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit); return json(res, { lines: out }); } if (req.method === 'POST') { let body = ''; req.on('data', c => { body += c; }); req.on('end', () => { posts.push({ t: now(), path: rest, body: body.slice(0, 300) }); console.log('POST', rest, body.slice(0, 200)); if (rest === '/api/pause') paused = true; if (rest === '/api/resume') paused = false; if (rest === '/api/key/reveal') return json(res, { ok: true, private_key: '0x' + 'ab'.repeat(32) }); if (rest === '/api/card') { let bytes = 0; try { bytes = Buffer.from((JSON.parse(body).png || '').split(',')[1] || '', 'base64').length; } catch (e) { } return json(res, { ok: true, path: '/Users/you/Library/Application Support/Igneum/cards/igneum-block-block-1-' + Math.floor(now()) + '.png', bytes }); } if (rest === '/api/setup') return json(res, { ok: true, address: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8', private_key: '0x' + 'cd'.repeat(32), wallet_file: '/Users/you/Library/Application Support/Igneum/app/wallet.json' }); json(res, { ok: true }); }); return; } if (rest === '/coin.png') return send(res, 200, types['.png'], fs.readFileSync(coin), true); const file = path.normalize(path.join(uiDir, rest)); if (!file.startsWith(uiDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found'); send(res, 200, types[path.extname(file)] || 'application/octet-stream', fs.readFileSync(file), rest.startsWith('/fonts/')); }).listen(port, '127.0.0.1', () => console.log('ui-mock on http://127.0.0.1:' + port + PREFIX + '/ scenario=' + scenario));