dexterm-cli 0.0.0-stage → 0.1.1
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 +661 -0
- package/dist/packages/agents/src/audit.js +122 -0
- package/dist/packages/agents/src/bus.js +379 -0
- package/dist/packages/agents/src/executor.js +177 -0
- package/dist/packages/agents/src/gate.js +176 -0
- package/dist/packages/agents/src/identity.js +179 -0
- package/dist/packages/agents/src/index.js +15 -0
- package/dist/packages/agents/src/proposals.js +153 -0
- package/dist/packages/agents/src/schema.js +87 -0
- package/dist/packages/agents/src/sdk.js +140 -0
- package/dist/packages/agents/src/tiers.js +114 -0
- package/dist/packages/agents/src/tools.js +69 -0
- package/dist/packages/agents/src/ws.js +158 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
- package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +67 -0
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +64 -0
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
- package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
- package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
- package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
- package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
- package/dist/packages/cli/bin/dexterm.mjs +11 -0
- package/dist/packages/cli/src/agent-cmd.js +303 -0
- package/dist/packages/cli/src/agent-roster.js +26 -0
- package/dist/packages/cli/src/analyst/backend.js +526 -0
- package/dist/packages/cli/src/analyst/results.js +38 -0
- package/dist/packages/cli/src/analyst/session.js +319 -0
- package/dist/packages/cli/src/audit.js +51 -0
- package/dist/packages/cli/src/backtest-cmd.js +149 -0
- package/dist/packages/cli/src/boot-state.js +62 -0
- package/dist/packages/cli/src/custom.js +261 -0
- package/dist/packages/cli/src/display.js +191 -0
- package/dist/packages/cli/src/doctor.js +361 -0
- package/dist/packages/cli/src/feed.js +634 -0
- package/dist/packages/cli/src/fixtures.js +275 -0
- package/dist/packages/cli/src/follow-cmd.js +221 -0
- package/dist/packages/cli/src/images-config.js +59 -0
- package/dist/packages/cli/src/index.js +31 -0
- package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
- package/dist/packages/cli/src/larp-record.js +21 -0
- package/dist/packages/cli/src/live/copy.js +80 -0
- package/dist/packages/cli/src/live/flow.js +233 -0
- package/dist/packages/cli/src/live/index.js +509 -0
- package/dist/packages/cli/src/live/intel.js +227 -0
- package/dist/packages/cli/src/live/newpairs.js +334 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
- package/dist/packages/cli/src/live/screens/token-live.js +459 -0
- package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
- package/dist/packages/cli/src/live/token.js +585 -0
- package/dist/packages/cli/src/live/trade.js +929 -0
- package/dist/packages/cli/src/live/watchlist.js +178 -0
- package/dist/packages/cli/src/main.js +5583 -0
- package/dist/packages/cli/src/narrative-cmd.js +76 -0
- package/dist/packages/cli/src/pack-cmd.js +323 -0
- package/dist/packages/cli/src/plan-cmd.js +278 -0
- package/dist/packages/cli/src/popup.js +810 -0
- package/dist/packages/cli/src/rules-cmd.js +131 -0
- package/dist/packages/cli/src/rules.js +339 -0
- package/dist/packages/cli/src/scan/command.js +57 -0
- package/dist/packages/cli/src/scan/engine.js +419 -0
- package/dist/packages/cli/src/scan/observe.js +64 -0
- package/dist/packages/cli/src/scan/ui.js +98 -0
- package/dist/packages/cli/src/screener/filter.js +241 -0
- package/dist/packages/cli/src/screener/presets.js +80 -0
- package/dist/packages/cli/src/screener/rows.js +146 -0
- package/dist/packages/cli/src/screener/rug.js +50 -0
- package/dist/packages/cli/src/screener/sort.js +52 -0
- package/dist/packages/cli/src/screener/view.js +183 -0
- package/dist/packages/cli/src/screens/analyst-session.js +343 -0
- package/dist/packages/cli/src/screens/analyst.js +326 -0
- package/dist/packages/cli/src/screens/boot.js +74 -0
- package/dist/packages/cli/src/screens/bubbles.js +255 -0
- package/dist/packages/cli/src/screens/coin.js +33 -0
- package/dist/packages/cli/src/screens/follows.js +201 -0
- package/dist/packages/cli/src/screens/kol.js +90 -0
- package/dist/packages/cli/src/screens/narrative.js +90 -0
- package/dist/packages/cli/src/screens/newpairs.js +150 -0
- package/dist/packages/cli/src/screens/overlay.js +257 -0
- package/dist/packages/cli/src/screens/plans.js +126 -0
- package/dist/packages/cli/src/screens/positions.js +176 -0
- package/dist/packages/cli/src/screens/rules.js +170 -0
- package/dist/packages/cli/src/screens/screener.js +459 -0
- package/dist/packages/cli/src/screens/social-panel.js +107 -0
- package/dist/packages/cli/src/screens/ticket.js +342 -0
- package/dist/packages/cli/src/screens/token.js +312 -0
- package/dist/packages/cli/src/screens/trending.js +181 -0
- package/dist/packages/cli/src/screens/watchlist.js +104 -0
- package/dist/packages/cli/src/social/demo.js +96 -0
- package/dist/packages/cli/src/social/view.js +250 -0
- package/dist/packages/cli/src/state.js +463 -0
- package/dist/packages/cli/src/theme-cmd.js +126 -0
- package/dist/packages/cli/src/trade.js +319 -0
- package/dist/packages/cli/src/voice-hud.js +42 -0
- package/dist/packages/cli/src/wallet-cmd.js +440 -0
- package/dist/packages/cli/src/wallet-command.js +31 -0
- package/dist/packages/cli/src/widgets.js +208 -0
- package/dist/packages/cli/src/workspace-cmd.js +68 -0
- package/dist/packages/cli/src/x-cmd.js +154 -0
- package/dist/packages/custom/src/config.js +213 -0
- package/dist/packages/custom/src/index.js +10 -0
- package/dist/packages/custom/src/keymap.js +107 -0
- package/dist/packages/custom/src/layout.js +235 -0
- package/dist/packages/custom/src/packs.js +633 -0
- package/dist/packages/custom/src/rules.js +704 -0
- package/dist/packages/custom/src/schema.js +515 -0
- package/dist/packages/custom/src/themes.js +176 -0
- package/dist/packages/custom/src/widget-html.js +222 -0
- package/dist/packages/custom/src/workspace.js +141 -0
- package/dist/packages/data/src/analyst-actions.js +133 -0
- package/dist/packages/data/src/budget.js +88 -0
- package/dist/packages/data/src/cache.js +142 -0
- package/dist/packages/data/src/candles.js +168 -0
- package/dist/packages/data/src/clock.js +64 -0
- package/dist/packages/data/src/credits.js +137 -0
- package/dist/packages/data/src/detail.js +97 -0
- package/dist/packages/data/src/fast-lane.js +321 -0
- package/dist/packages/data/src/fixtures.js +135 -0
- package/dist/packages/data/src/health.js +245 -0
- package/dist/packages/data/src/http.js +277 -0
- package/dist/packages/data/src/images/cache.js +141 -0
- package/dist/packages/data/src/images/decode-worker.js +28 -0
- package/dist/packages/data/src/images/decode.js +81 -0
- package/dist/packages/data/src/images/fetch.js +162 -0
- package/dist/packages/data/src/images/gif.js +214 -0
- package/dist/packages/data/src/images/index.js +10 -0
- package/dist/packages/data/src/images/jpeg.js +544 -0
- package/dist/packages/data/src/images/png.js +310 -0
- package/dist/packages/data/src/images/service.js +334 -0
- package/dist/packages/data/src/images/types.js +27 -0
- package/dist/packages/data/src/index.js +208 -0
- package/dist/packages/data/src/intel/helius-history.js +59 -0
- package/dist/packages/data/src/intel/holder-class.js +125 -0
- package/dist/packages/data/src/intel/rules.js +421 -0
- package/dist/packages/data/src/intel/scan.js +170 -0
- package/dist/packages/data/src/larp/evidence.js +288 -0
- package/dist/packages/data/src/larp/links.js +137 -0
- package/dist/packages/data/src/larp/robots.js +90 -0
- package/dist/packages/data/src/larp/safe-fetch.js +389 -0
- package/dist/packages/data/src/limiter.js +341 -0
- package/dist/packages/data/src/model.js +12 -0
- package/dist/packages/data/src/paper.js +331 -0
- package/dist/packages/data/src/rpc-config.js +126 -0
- package/dist/packages/data/src/rpc-pool.js +166 -0
- package/dist/packages/data/src/social/chain.js +83 -0
- package/dist/packages/data/src/social/dedupe.js +205 -0
- package/dist/packages/data/src/social/feed.js +462 -0
- package/dist/packages/data/src/social/firewall.js +110 -0
- package/dist/packages/data/src/social/index.js +11 -0
- package/dist/packages/data/src/social/kol.js +269 -0
- package/dist/packages/data/src/social/model.js +11 -0
- package/dist/packages/data/src/social/rank.js +184 -0
- package/dist/packages/data/src/social/rss.js +113 -0
- package/dist/packages/data/src/social/summary.js +104 -0
- package/dist/packages/data/src/social/venues.js +186 -0
- package/dist/packages/data/src/sources/dexscreener.js +170 -0
- package/dist/packages/data/src/sources/geckoterminal.js +133 -0
- package/dist/packages/data/src/sources/goplus.js +161 -0
- package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
- package/dist/packages/data/src/sources/jupiter.js +198 -0
- package/dist/packages/data/src/sources/pda.js +113 -0
- package/dist/packages/data/src/sources/pump-curve.js +58 -0
- package/dist/packages/data/src/sources/pumpportal.js +247 -0
- package/dist/packages/data/src/sources/rugcheck.js +71 -0
- package/dist/packages/data/src/sources/solana-rpc.js +299 -0
- package/dist/packages/data/src/sources/solana-ws.js +287 -0
- package/dist/packages/data/src/store.js +289 -0
- package/dist/packages/data/src/trade/bridge.js +464 -0
- package/dist/packages/data/src/trade/caps.js +323 -0
- package/dist/packages/data/src/trade/copy/auto.js +385 -0
- package/dist/packages/data/src/trade/copy/follows.js +103 -0
- package/dist/packages/data/src/trade/copy/index.js +9 -0
- package/dist/packages/data/src/trade/copy/mirror.js +91 -0
- package/dist/packages/data/src/trade/copy/stats.js +120 -0
- package/dist/packages/data/src/trade/copy/tracker.js +158 -0
- package/dist/packages/data/src/trade/copy/trades.js +87 -0
- package/dist/packages/data/src/trade/copy/types.js +6 -0
- package/dist/packages/data/src/trade/executor.js +933 -0
- package/dist/packages/data/src/trade/kill-switch.js +47 -0
- package/dist/packages/data/src/trade/link-store.js +52 -0
- package/dist/packages/data/src/trade/plans/confirm.js +157 -0
- package/dist/packages/data/src/trade/plans/engine.js +784 -0
- package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
- package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
- package/dist/packages/data/src/trade/plans/executor.js +11 -0
- package/dist/packages/data/src/trade/plans/index.js +12 -0
- package/dist/packages/data/src/trade/plans/journal.js +77 -0
- package/dist/packages/data/src/trade/plans/mandate.js +129 -0
- package/dist/packages/data/src/trade/plans/plan.js +179 -0
- package/dist/packages/data/src/trade/plans/service.js +168 -0
- package/dist/packages/data/src/trade/plans/types.js +6 -0
- package/dist/packages/data/src/trade/rpc-exec.js +229 -0
- package/dist/packages/data/src/trade/signer/client.js +197 -0
- package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
- package/dist/packages/data/src/trade/signer/host.js +97 -0
- package/dist/packages/data/src/trade/signer/main.js +121 -0
- package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
- package/dist/packages/data/src/trade/signer/protocol.js +92 -0
- package/dist/packages/data/src/trade/signer/service.js +400 -0
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
- package/dist/packages/data/src/trade/sol-usd.js +86 -0
- package/dist/packages/data/src/trade/swap-build.js +108 -0
- package/dist/packages/data/src/trade/token-delta.js +201 -0
- package/dist/packages/data/src/trade/tx-decode.js +632 -0
- package/dist/packages/data/src/util.js +24 -0
- package/dist/packages/data/src/watchlist.js +176 -0
- package/dist/packages/data/src/x/api.js +256 -0
- package/dist/packages/data/src/x/budget.js +66 -0
- package/dist/packages/data/src/x/config.js +49 -0
- package/dist/packages/data/src/x/index.js +10 -0
- package/dist/packages/data/src/x/mock-server.js +217 -0
- package/dist/packages/data/src/x/oauth.js +242 -0
- package/dist/packages/data/src/x/pkce.js +21 -0
- package/dist/packages/data/src/x/source.js +285 -0
- package/dist/packages/data/src/x/token-store.js +134 -0
- package/dist/packages/data/src/x/transport.js +71 -0
- package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
- package/dist/packages/mcp/assets/cases.json +1194 -0
- package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
- package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
- package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
- package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
- package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
- package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
- package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
- package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
- package/dist/packages/mcp/src/dossier.js +78 -0
- package/dist/packages/mcp/src/firewall.js +21 -0
- package/dist/packages/mcp/src/index.js +22 -0
- package/dist/packages/mcp/src/server.js +849 -0
- package/dist/packages/mcp/src/user-content.js +434 -0
- package/dist/packages/packs/src/backtest.js +183 -0
- package/dist/packages/packs/src/digest.js +95 -0
- package/dist/packages/packs/src/firewall.js +57 -0
- package/dist/packages/packs/src/index.js +19 -0
- package/dist/packages/packs/src/larp-eval.js +100 -0
- package/dist/packages/packs/src/narrative.js +309 -0
- package/dist/packages/packs/src/reason.js +135 -0
- package/dist/packages/packs/src/rug-model.js +137 -0
- package/dist/packages/packs/src/schema.js +54 -0
- package/dist/packages/packs/src/token-dossier.js +174 -0
- package/dist/packages/packs/src/verdicts.js +17 -0
- package/dist/packages/packs/src/wallet-dossier.js +105 -0
- package/dist/packages/popup/src/config.js +71 -0
- package/dist/packages/popup/src/dock.js +271 -0
- package/dist/packages/popup/src/host.js +210 -0
- package/dist/packages/popup/src/index.js +12 -0
- package/dist/packages/popup/src/loopback.js +53 -0
- package/dist/packages/popup/src/open.js +183 -0
- package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
- package/dist/packages/popup/src/pages/bubbles.css +23 -0
- package/dist/packages/popup/src/pages/bubbles.html +36 -0
- package/dist/packages/popup/src/pages/bubbles.js +214 -0
- package/dist/packages/popup/src/pages/chart-lib.js +262 -0
- package/dist/packages/popup/src/pages/chart.css +50 -0
- package/dist/packages/popup/src/pages/chart.html +56 -0
- package/dist/packages/popup/src/pages/chart.js +554 -0
- package/dist/packages/popup/src/pages/common.css +34 -0
- package/dist/packages/popup/src/pages/common.js +187 -0
- package/dist/packages/popup/src/pages/feeds.html +20 -0
- package/dist/packages/popup/src/pages/feeds.js +77 -0
- package/dist/packages/popup/src/pages/narrative.html +20 -0
- package/dist/packages/popup/src/pages/narrative.js +61 -0
- package/dist/packages/popup/src/pages/plans.css +30 -0
- package/dist/packages/popup/src/pages/plans.html +26 -0
- package/dist/packages/popup/src/pages/plans.js +82 -0
- package/dist/packages/popup/src/pages/roster.css +41 -0
- package/dist/packages/popup/src/pages/roster.html +25 -0
- package/dist/packages/popup/src/pages/roster.js +75 -0
- package/dist/packages/popup/src/pages/token.html +20 -0
- package/dist/packages/popup/src/pages/token.js +112 -0
- package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
- package/dist/packages/popup/src/pages/widget.css +5 -0
- package/dist/packages/popup/src/pages/widget.html +21 -0
- package/dist/packages/popup/src/pages/widget.js +85 -0
- package/dist/packages/popup/src/protocol.js +31 -0
- package/dist/packages/popup/src/selftest.js +156 -0
- package/dist/packages/popup/src/server.js +582 -0
- package/dist/packages/popup/src/sinkproxy.js +167 -0
- package/dist/packages/popup/src/widget-html.js +231 -0
- package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
- package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
- package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
- package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
- package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
- package/dist/packages/recorder/src/cli.js +113 -0
- package/dist/packages/recorder/src/export.js +71 -0
- package/dist/packages/recorder/src/index.js +20 -0
- package/dist/packages/recorder/src/labels.js +151 -0
- package/dist/packages/recorder/src/open.js +29 -0
- package/dist/packages/recorder/src/recorder.js +553 -0
- package/dist/packages/recorder/src/schedule.js +43 -0
- package/dist/packages/recorder/src/schema.js +135 -0
- package/dist/packages/recorder/src/snapshot.js +191 -0
- package/dist/packages/recorder/src/sweep.js +52 -0
- package/dist/packages/recorder/src/verdicts.js +52 -0
- package/dist/packages/tui/src/bubbles.js +415 -0
- package/dist/packages/tui/src/canvas.js +370 -0
- package/dist/packages/tui/src/chrome.js +85 -0
- package/dist/packages/tui/src/color.js +133 -0
- package/dist/packages/tui/src/flush.js +62 -0
- package/dist/packages/tui/src/fmt.js +188 -0
- package/dist/packages/tui/src/fx-trade.js +278 -0
- package/dist/packages/tui/src/fx.js +527 -0
- package/dist/packages/tui/src/images/blocks.js +141 -0
- package/dist/packages/tui/src/images/coin.js +22 -0
- package/dist/packages/tui/src/images/detect.js +140 -0
- package/dist/packages/tui/src/images/index.js +7 -0
- package/dist/packages/tui/src/images/layer.js +146 -0
- package/dist/packages/tui/src/images/pixels.js +157 -0
- package/dist/packages/tui/src/images/protocols.js +87 -0
- package/dist/packages/tui/src/index.js +15 -0
- package/dist/packages/tui/src/keymap.js +355 -0
- package/dist/packages/tui/src/layout.js +11 -0
- package/dist/packages/tui/src/sound.js +177 -0
- package/dist/packages/tui/src/theme.js +182 -0
- package/dist/packages/tui/src/widgets.js +598 -0
- package/dist/packages/voice/src/controller.js +395 -0
- package/dist/packages/voice/src/doctor.js +63 -0
- package/dist/packages/voice/src/engines.js +44 -0
- package/dist/packages/voice/src/index.js +16 -0
- package/dist/packages/voice/src/router.js +502 -0
- package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
- package/dist/packages/voice/src/stt/apple.js +179 -0
- package/dist/packages/voice/src/stt/fake.js +34 -0
- package/dist/packages/voice/src/stt/whisper.js +102 -0
- package/dist/packages/voice/src/summary.js +44 -0
- package/dist/packages/voice/src/tts/fake.js +30 -0
- package/dist/packages/voice/src/tts/say.js +96 -0
- package/dist/packages/voice/src/types.js +3 -0
- package/package.json +42 -4
- package/README.md +0 -3
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Per-source health registry (SPEC §3.5, learning CL-08).
|
|
2
|
+
//
|
|
3
|
+
// Rules: three consecutive failures, or two 429s inside a minute, flip a source
|
|
4
|
+
// to `degraded` for a backoff window (30 s, doubling per repeat up to 10 min).
|
|
5
|
+
// When the window ends the source is `probing` (half-open): requests are allowed
|
|
6
|
+
// again, one success returns it to `ok` and resets the backoff, one failure
|
|
7
|
+
// re-degrades it with the doubled window. Keyed sources without a key are
|
|
8
|
+
// `needs-key`; feature-flagged sources can be `disabled`.
|
|
9
|
+
//
|
|
10
|
+
// The registry gates which columns, features and AI tools are offered.
|
|
11
|
+
import { systemClock } from "./clock.js";
|
|
12
|
+
export const DEFAULT_HEALTH_POLICY = {
|
|
13
|
+
failureThreshold: 3,
|
|
14
|
+
rateLimitThreshold: 2,
|
|
15
|
+
rateLimitWindowMs: 60_000,
|
|
16
|
+
baseBackoffMs: 30_000,
|
|
17
|
+
maxBackoffMs: 600_000,
|
|
18
|
+
};
|
|
19
|
+
/** Sources that cannot work without a user-supplied key (SPEC §3.2 Tier 1). */
|
|
20
|
+
export const KEYED_SOURCES = new Set(['helius', 'birdeye']);
|
|
21
|
+
export class HealthRegistry {
|
|
22
|
+
entries = new Map();
|
|
23
|
+
clock;
|
|
24
|
+
policy;
|
|
25
|
+
keys;
|
|
26
|
+
disabled;
|
|
27
|
+
usage;
|
|
28
|
+
listeners = new Set();
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
this.clock = opts.clock ?? systemClock;
|
|
31
|
+
this.policy = { ...DEFAULT_HEALTH_POLICY, ...opts.policy };
|
|
32
|
+
this.keys = { ...opts.keys };
|
|
33
|
+
this.disabled = new Set(opts.disabled ?? []);
|
|
34
|
+
this.usage = opts.usage;
|
|
35
|
+
}
|
|
36
|
+
onChange(fn) {
|
|
37
|
+
this.listeners.add(fn);
|
|
38
|
+
return () => this.listeners.delete(fn);
|
|
39
|
+
}
|
|
40
|
+
setKey(source, present) {
|
|
41
|
+
const before = this.state(source);
|
|
42
|
+
this.keys[source] = present;
|
|
43
|
+
this.rebase(source);
|
|
44
|
+
this.emitIfChanged(source, before);
|
|
45
|
+
}
|
|
46
|
+
setDisabled(source, disabled) {
|
|
47
|
+
const before = this.state(source);
|
|
48
|
+
if (disabled)
|
|
49
|
+
this.disabled.add(source);
|
|
50
|
+
else
|
|
51
|
+
this.disabled.delete(source);
|
|
52
|
+
this.rebase(source);
|
|
53
|
+
this.emitIfChanged(source, before);
|
|
54
|
+
}
|
|
55
|
+
/** Re-derive key/flag state; a degraded source stays degraded unless it is now gated off. */
|
|
56
|
+
rebase(source) {
|
|
57
|
+
const e = this.entry(source);
|
|
58
|
+
const base = this.initialBase(source);
|
|
59
|
+
if (e.base !== 'degraded' || base !== 'ok') {
|
|
60
|
+
if (e.base !== base)
|
|
61
|
+
e.since = this.clock.now();
|
|
62
|
+
e.base = base;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Record the outcome of one call to `source`. */
|
|
66
|
+
record(source, outcome) {
|
|
67
|
+
const now = this.clock.now();
|
|
68
|
+
const before = this.state(source);
|
|
69
|
+
const e = this.entry(source);
|
|
70
|
+
if (outcome.latencyMs !== undefined)
|
|
71
|
+
e.latencyMs = outcome.latencyMs;
|
|
72
|
+
if (outcome.ok) {
|
|
73
|
+
e.okCount++;
|
|
74
|
+
e.consecutiveFailures = 0;
|
|
75
|
+
if (e.base === 'degraded') {
|
|
76
|
+
e.base = 'ok';
|
|
77
|
+
e.since = now;
|
|
78
|
+
e.degradedUntil = 0;
|
|
79
|
+
e.backoffMs = 0;
|
|
80
|
+
e.nextBackoffMs = this.policy.baseBackoffMs;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
e.errorCount++;
|
|
85
|
+
e.consecutiveFailures++;
|
|
86
|
+
e.lastError = outcome.error ?? (outcome.status ? `HTTP ${outcome.status}` : 'error');
|
|
87
|
+
if (outcome.status === 429)
|
|
88
|
+
e.recent429.push(now);
|
|
89
|
+
e.recent429 = e.recent429.filter((t) => t > now - this.policy.rateLimitWindowMs);
|
|
90
|
+
const probing = before === 'probing';
|
|
91
|
+
const tripped = e.consecutiveFailures >= this.policy.failureThreshold || e.recent429.length >= this.policy.rateLimitThreshold;
|
|
92
|
+
if ((probing || tripped) && (e.base === 'ok' || e.base === 'degraded') && before !== 'degraded') {
|
|
93
|
+
this.degrade(e, now);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.emitIfChanged(source, before);
|
|
97
|
+
}
|
|
98
|
+
state(source) {
|
|
99
|
+
const e = this.entry(source);
|
|
100
|
+
if (e.base === 'degraded')
|
|
101
|
+
return this.clock.now() >= e.degradedUntil ? 'probing' : 'degraded';
|
|
102
|
+
return e.base;
|
|
103
|
+
}
|
|
104
|
+
/** True when requests to `source` should be attempted (ok or probing). */
|
|
105
|
+
isAvailable(source) {
|
|
106
|
+
const s = this.state(source);
|
|
107
|
+
return s === 'ok' || s === 'probing';
|
|
108
|
+
}
|
|
109
|
+
get(source) {
|
|
110
|
+
const e = this.entry(source);
|
|
111
|
+
return {
|
|
112
|
+
source,
|
|
113
|
+
state: this.state(source),
|
|
114
|
+
since: e.since,
|
|
115
|
+
consecutiveFailures: e.consecutiveFailures,
|
|
116
|
+
degradedUntil: e.degradedUntil,
|
|
117
|
+
backoffMs: e.backoffMs,
|
|
118
|
+
latencyMs: e.latencyMs,
|
|
119
|
+
lastError: e.lastError,
|
|
120
|
+
okCount: e.okCount,
|
|
121
|
+
errorCount: e.errorCount,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** SPEC §3.3 `Health` records for every source seen (or the ones asked for). */
|
|
125
|
+
snapshot(sources) {
|
|
126
|
+
const ids = sources ?? [...this.entries.keys()];
|
|
127
|
+
return ids.map((source) => {
|
|
128
|
+
const h = this.get(source);
|
|
129
|
+
const u = this.usage?.(source);
|
|
130
|
+
return {
|
|
131
|
+
source,
|
|
132
|
+
ok: h.state === 'ok' || h.state === 'probing',
|
|
133
|
+
state: h.state === 'probing' ? 'degraded' : h.state,
|
|
134
|
+
latencyMs: h.latencyMs ?? null,
|
|
135
|
+
rpmUsed: u?.rpmUsed ?? 0,
|
|
136
|
+
rpmLimit: u?.rpmLimit ?? null,
|
|
137
|
+
lastError: h.lastError ?? null,
|
|
138
|
+
since: h.since,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
degrade(e, now) {
|
|
143
|
+
e.base = 'degraded';
|
|
144
|
+
e.since = now;
|
|
145
|
+
e.backoffMs = e.nextBackoffMs;
|
|
146
|
+
e.degradedUntil = now + e.backoffMs;
|
|
147
|
+
e.nextBackoffMs = Math.min(e.nextBackoffMs * 2, this.policy.maxBackoffMs);
|
|
148
|
+
e.consecutiveFailures = 0;
|
|
149
|
+
e.recent429 = [];
|
|
150
|
+
}
|
|
151
|
+
initialBase(source) {
|
|
152
|
+
if (this.disabled.has(source))
|
|
153
|
+
return 'disabled';
|
|
154
|
+
if (KEYED_SOURCES.has(source) && !this.keys[source])
|
|
155
|
+
return 'needs-key';
|
|
156
|
+
return 'ok';
|
|
157
|
+
}
|
|
158
|
+
entry(source) {
|
|
159
|
+
let e = this.entries.get(source);
|
|
160
|
+
if (!e) {
|
|
161
|
+
e = {
|
|
162
|
+
source,
|
|
163
|
+
base: this.initialBase(source),
|
|
164
|
+
since: this.clock.now(),
|
|
165
|
+
consecutiveFailures: 0,
|
|
166
|
+
recent429: [],
|
|
167
|
+
degradedUntil: 0,
|
|
168
|
+
backoffMs: 0,
|
|
169
|
+
nextBackoffMs: this.policy.baseBackoffMs,
|
|
170
|
+
okCount: 0,
|
|
171
|
+
errorCount: 0,
|
|
172
|
+
};
|
|
173
|
+
this.entries.set(source, e);
|
|
174
|
+
}
|
|
175
|
+
return e;
|
|
176
|
+
}
|
|
177
|
+
emitIfChanged(source, before) {
|
|
178
|
+
const after = this.state(source);
|
|
179
|
+
if (after !== before)
|
|
180
|
+
for (const fn of this.listeners)
|
|
181
|
+
fn(source, after);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Screener columns (SPEC §4.2) and the sources that can fill them. */
|
|
185
|
+
export const COLUMN_SOURCES = {
|
|
186
|
+
// Jupiter Tokens v2 carries price/stats too, so these survive a DexScreener outage.
|
|
187
|
+
price: { anyOf: ['dexscreener', 'jupiter-tokens', 'jupiter-price'] },
|
|
188
|
+
'5m': { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
189
|
+
'1h': { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
190
|
+
'6h': { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
191
|
+
'24h': { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
192
|
+
vol: { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
193
|
+
liq: { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
194
|
+
mcap: { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
195
|
+
age: { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
196
|
+
bs: { anyOf: ['dexscreener', 'jupiter-tokens'], label: 'buy/sell txns' },
|
|
197
|
+
sparkline: { anyOf: ['dexscreener', 'jupiter-tokens'], label: 'local history of polled prices' },
|
|
198
|
+
holders: { anyOf: ['jupiter-tokens'] },
|
|
199
|
+
org: { anyOf: ['jupiter-tokens'], label: 'organic score' },
|
|
200
|
+
flags: { anyOf: ['jupiter-tokens', 'solana-rpc', 'helius', 'goplus'], label: 'mint/freeze authority' },
|
|
201
|
+
launchpad: { anyOf: ['jupiter-tokens'] },
|
|
202
|
+
bonding: { anyOf: ['pumpfun', 'pumpportal'] },
|
|
203
|
+
dev: { anyOf: ['jupiter-tokens', 'helius'] },
|
|
204
|
+
snipers: { anyOf: ['helius'] },
|
|
205
|
+
bundled: { anyOf: ['helius'] },
|
|
206
|
+
};
|
|
207
|
+
/** Screens, panels and AI tools that depend on specific sources (SPEC §3.2, §5.2). */
|
|
208
|
+
export const FEATURE_SOURCES = {
|
|
209
|
+
screener: { anyOf: ['dexscreener', 'jupiter-tokens'] },
|
|
210
|
+
'sol-price': { anyOf: ['jupiter-price', 'dexscreener'] },
|
|
211
|
+
candles: { anyOf: ['geckoterminal', 'birdeye'] },
|
|
212
|
+
'top-holders': { anyOf: ['helius', 'solana-rpc'] },
|
|
213
|
+
'new-pairs-live': { anyOf: ['pumpportal', 'jupiter-tokens'] },
|
|
214
|
+
rugcheck: { anyOf: ['rugcheck'] },
|
|
215
|
+
security: { anyOf: ['rugcheck', 'goplus'], label: 'risk opinions' },
|
|
216
|
+
authorities: { anyOf: ['helius', 'solana-rpc', 'goplus', 'jupiter-tokens'] },
|
|
217
|
+
forensics: { anyOf: ['helius'] },
|
|
218
|
+
'dev-history': { anyOf: ['helius', 'jupiter-tokens'] },
|
|
219
|
+
'top-traders': { anyOf: ['birdeye', 'helius'] },
|
|
220
|
+
'live-tape': { anyOf: ['geckoterminal', 'helius'] },
|
|
221
|
+
'new-pairs-poll': { anyOf: ['jupiter-tokens', 'rugcheck'] },
|
|
222
|
+
// Paper trading (task-1310): the ticket prices fills from the keyless swap quote only.
|
|
223
|
+
'trade-quote': { anyOf: ['jupiter-swap'], label: 'paper trade quote' },
|
|
224
|
+
};
|
|
225
|
+
const GATE_RANK = { ok: 0, degraded: 1, 'needs-key': 2, disabled: 3 };
|
|
226
|
+
export function gate(registry, id, spec) {
|
|
227
|
+
const via = spec.anyOf.filter((s) => registry.isAvailable(s));
|
|
228
|
+
if (via.length > 0)
|
|
229
|
+
return { id, state: 'ok', available: true, via };
|
|
230
|
+
// Report the most actionable reason: degraded beats needs-key beats disabled.
|
|
231
|
+
let best = 'disabled';
|
|
232
|
+
for (const s of spec.anyOf) {
|
|
233
|
+
const st = registry.state(s);
|
|
234
|
+
const g = st === 'degraded' ? 'degraded' : st === 'needs-key' ? 'needs-key' : st === 'disabled' ? 'disabled' : 'ok';
|
|
235
|
+
if (GATE_RANK[g] < GATE_RANK[best])
|
|
236
|
+
best = g;
|
|
237
|
+
}
|
|
238
|
+
return { id, state: best, available: false, via };
|
|
239
|
+
}
|
|
240
|
+
export function gateAll(registry, specs) {
|
|
241
|
+
const out = {};
|
|
242
|
+
for (const [id, spec] of Object.entries(specs))
|
|
243
|
+
out[id] = gate(registry, id, spec);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Keyless, read-only HTTP client. Every call goes through the health gate and
|
|
2
|
+
// the rate limiter, is timed, and reports its outcome to the health registry.
|
|
3
|
+
// GET for REST sources; POST only for JSON-RPC reads (the Solana RPC adapter
|
|
4
|
+
// allow-lists read-only methods), never with auth headers.
|
|
5
|
+
// The transport is pluggable: live fetch, recorder (live + capture) or replayer
|
|
6
|
+
// (fixtures only, never touches the network).
|
|
7
|
+
import { systemClock } from "./clock.js";
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
import { PriorityRef } from "./limiter.js";
|
|
10
|
+
import { redactSecrets } from "./rpc-config.js";
|
|
11
|
+
/** Response headers worth keeping (rate-limit hints); everything else is dropped. */
|
|
12
|
+
const KEEP_HEADERS = ['content-type', 'retry-after', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'];
|
|
13
|
+
/** Real network via global fetch. GET only, no auth headers, no cookies. */
|
|
14
|
+
export function liveTransport(opts = {}) {
|
|
15
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
16
|
+
const userAgent = opts.userAgent ?? 'dexterm/0.0.1 (+local terminal screener; keyless read-only)';
|
|
17
|
+
const shutdownSignal = opts.shutdownSignal;
|
|
18
|
+
return async (req) => {
|
|
19
|
+
const t0 = performance.now();
|
|
20
|
+
const headers = { accept: req.accept ?? 'application/json', 'user-agent': userAgent };
|
|
21
|
+
if (req.body !== undefined)
|
|
22
|
+
headers['content-type'] = 'application/json';
|
|
23
|
+
// Own the timeout + shutdown abort via one controller instead of AbortSignal.timeout(): the latter
|
|
24
|
+
// schedules an internal *ref'd* timer that is never cancelled when the fetch settles (or when a
|
|
25
|
+
// companion signal fires), so each live request left a ref'd ~10 s timer keeping the event loop
|
|
26
|
+
// alive — the 10-33 s quit hang (task-1319, QA M1). This timer is unref'd (never holds the loop on
|
|
27
|
+
// its own) and cleared the instant the request settles, and the shutdown signal aborts it at once.
|
|
28
|
+
// The caller may also pass its own abort (task-1310: bounded confirm quote); whichever fires first wins.
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const onTimeout = () => controller.abort(new DOMException(`timed out after ${timeoutMs}ms`, 'TimeoutError'));
|
|
31
|
+
const onShutdown = () => controller.abort(new DOMException('shutting down', 'AbortError'));
|
|
32
|
+
const onCaller = () => controller.abort(req.signal?.reason ?? new DOMException('aborted by caller', 'AbortError'));
|
|
33
|
+
const timer = setTimeout(onTimeout, timeoutMs);
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
if (shutdownSignal) {
|
|
36
|
+
if (shutdownSignal.aborted)
|
|
37
|
+
onShutdown();
|
|
38
|
+
else
|
|
39
|
+
shutdownSignal.addEventListener('abort', onShutdown, { once: true });
|
|
40
|
+
}
|
|
41
|
+
if (req.signal) {
|
|
42
|
+
if (req.signal.aborted)
|
|
43
|
+
onCaller();
|
|
44
|
+
else
|
|
45
|
+
req.signal.addEventListener('abort', onCaller, { once: true });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const res = await globalThis.fetch(req.url, {
|
|
49
|
+
method: req.method,
|
|
50
|
+
headers,
|
|
51
|
+
body: req.body,
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
const body = await res.text();
|
|
55
|
+
const kept = {};
|
|
56
|
+
for (const h of KEEP_HEADERS) {
|
|
57
|
+
const v = res.headers.get(h);
|
|
58
|
+
if (v !== null)
|
|
59
|
+
kept[h] = v;
|
|
60
|
+
}
|
|
61
|
+
return { status: res.status, headers: kept, body, latencyMs: Math.round(performance.now() - t0) };
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
shutdownSignal?.removeEventListener('abort', onShutdown);
|
|
66
|
+
req.signal?.removeEventListener('abort', onCaller);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse a `Retry-After` header (seconds) to ms; undefined when absent or not a positive number. task-1368:
|
|
72
|
+
* Jupiter's keyless gateway sends NO Retry-After on a 429 (recorded 2026-10-12, fixtures/trade/jupiter-429-*.json)
|
|
73
|
+
* but does send `x-ratelimit-reset` (epoch seconds), so when Retry-After is absent the wait is derived from the
|
|
74
|
+
* reset time instead — the honest number for "retry in Ns" and for the retry policy.
|
|
75
|
+
*/
|
|
76
|
+
function retryAfterMs(res, now) {
|
|
77
|
+
const v = Number(res.headers['retry-after']);
|
|
78
|
+
if (Number.isFinite(v) && v > 0)
|
|
79
|
+
return v * 1000;
|
|
80
|
+
if (now !== undefined) {
|
|
81
|
+
const reset = Number(res.headers['x-ratelimit-reset']);
|
|
82
|
+
if (Number.isFinite(reset) && reset > 0) {
|
|
83
|
+
const until = reset * 1000 - now;
|
|
84
|
+
if (until > 0)
|
|
85
|
+
return Math.min(until, MAX_HEADER_PAUSE_MS);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const MAX_HEADER_PAUSE_MS = 60_000;
|
|
91
|
+
/** Cap for the doubling pause after consecutive 429s on one bucket (matches the health backoff cap). */
|
|
92
|
+
const MAX_429_PAUSE_MS = 600_000;
|
|
93
|
+
export class HttpError extends Error {
|
|
94
|
+
status;
|
|
95
|
+
url;
|
|
96
|
+
/** Retry-After in ms when the server sent one (429/503); used by the RPC pool to pause an endpoint. */
|
|
97
|
+
retryAfterMs;
|
|
98
|
+
constructor(status, url, message, retryAfterMs) {
|
|
99
|
+
// URLs may carry an RPC api-key: never let one into a message that can reach a log or the screen.
|
|
100
|
+
super(redactSecrets(message ?? `HTTP ${status} for ${url}`));
|
|
101
|
+
this.name = 'HttpError';
|
|
102
|
+
this.status = status;
|
|
103
|
+
this.url = redactSecrets(url);
|
|
104
|
+
if (retryAfterMs !== undefined)
|
|
105
|
+
this.retryAfterMs = retryAfterMs;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export class SourceUnavailableError extends Error {
|
|
109
|
+
source;
|
|
110
|
+
constructor(source, state) {
|
|
111
|
+
super(`source ${source} is ${state}`);
|
|
112
|
+
this.name = 'SourceUnavailableError';
|
|
113
|
+
this.source = source;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Request priority context (task-1323). Code that knows how urgent its requests are wraps the call:
|
|
118
|
+
* `withPriority('high', () => dl.detail.candles(...))`; every request issued inside (through any number
|
|
119
|
+
* of awaits, caches and adapters) is queued at that priority in its rate bucket. Without a context a
|
|
120
|
+
* request is 'normal' — the pre-1323 behaviour. Passing a PriorityRef shares it, so a later raise()
|
|
121
|
+
* (a foreground caller coalescing onto a background load) re-prioritises requests already queued.
|
|
122
|
+
*/
|
|
123
|
+
const priorityContext = new AsyncLocalStorage();
|
|
124
|
+
export function withPriority(priority, fn) {
|
|
125
|
+
return priorityContext.run(typeof priority === 'string' ? new PriorityRef(priority) : priority, fn);
|
|
126
|
+
}
|
|
127
|
+
/** The caller's priority context, if any (undefined = normal). */
|
|
128
|
+
export function currentPriority() {
|
|
129
|
+
return priorityContext.getStore();
|
|
130
|
+
}
|
|
131
|
+
export class HttpClient {
|
|
132
|
+
opts;
|
|
133
|
+
/** The clock every wait in this client runs on (a FakeClock in tests); adapters pace their own retries by it. */
|
|
134
|
+
clock;
|
|
135
|
+
inflight = new Map();
|
|
136
|
+
/** Consecutive 429s per bucket, for the doubling pause. Reset by any non-429 response. */
|
|
137
|
+
strikes = new Map();
|
|
138
|
+
constructor(opts) {
|
|
139
|
+
this.opts = opts;
|
|
140
|
+
this.clock = opts.clock ?? systemClock;
|
|
141
|
+
}
|
|
142
|
+
/** GET JSON. Identical in-flight URLs share one request (SPEC §3.4 coalescing). */
|
|
143
|
+
getJson(url, meta, check, signal) {
|
|
144
|
+
return this.coalesce(`GET ${url}`, { method: 'GET', url, signal }, meta, check);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* POST a JSON body (JSON-RPC reads only). Identical in-flight url+body pairs share one request.
|
|
148
|
+
* `signal` (task-1368) bounds the wait like getJson's: a caller deadline cuts the limiter queue and the
|
|
149
|
+
* transport, and an abortable POST is never shared.
|
|
150
|
+
*/
|
|
151
|
+
postJson(url, body, meta, check, signal) {
|
|
152
|
+
const text = JSON.stringify(body);
|
|
153
|
+
return this.coalesce(`POST ${url} ${text}`, { method: 'POST', url, body: text, signal }, meta, check);
|
|
154
|
+
}
|
|
155
|
+
/** Ms until a DEGRADED source is probed again (0 when it is available or unknown). The health gate's own cool-down. */
|
|
156
|
+
unavailableFor(source) {
|
|
157
|
+
try {
|
|
158
|
+
const h = this.opts.health.get(source);
|
|
159
|
+
return h.state === 'degraded' ? Math.max(0, h.degradedUntil - this.clock.now()) : 0;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Remaining pause on a rate bucket in ms (0 when it is not paused or unknown). For honest "retry in Ns" messages. */
|
|
166
|
+
pausedFor(bucket) {
|
|
167
|
+
try {
|
|
168
|
+
const until = this.opts.limiter.bucket(bucket).stats().pausedUntil;
|
|
169
|
+
return until > 0 ? Math.max(0, until - this.clock.now()) : 0;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* GET a text document (task-1330: RSS/XML news feeds). Same gate, limiter, health accounting,
|
|
177
|
+
* coalescing and rate-limit header handling as getJson; the body is returned unparsed.
|
|
178
|
+
*/
|
|
179
|
+
getText(url, meta, accept = 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.5') {
|
|
180
|
+
return this.coalesce(`GET ${url}`, { method: 'GET', url, accept }, meta, undefined, true);
|
|
181
|
+
}
|
|
182
|
+
coalesce(key, req, meta, check, raw = false) {
|
|
183
|
+
const caller = currentPriority();
|
|
184
|
+
// An abortable request is never shared (task-1310): a caller that cancels (e.g. a bounded confirm
|
|
185
|
+
// quote) must not cancel an unrelated caller, and must not be ignored by joining a non-abortable one.
|
|
186
|
+
if (req.signal)
|
|
187
|
+
return this.doRequest(req, meta, check, new PriorityRef(caller?.level ?? 'normal'));
|
|
188
|
+
const existing = this.inflight.get(key);
|
|
189
|
+
if (existing) {
|
|
190
|
+
// A more urgent caller joining a queued request lifts it (task-1323): never inherit a lower priority.
|
|
191
|
+
if (caller)
|
|
192
|
+
existing.ref.raise(caller.level);
|
|
193
|
+
return existing.p;
|
|
194
|
+
}
|
|
195
|
+
// Each request gets its own ref seeded from the caller's level and following the caller's raises,
|
|
196
|
+
// so raising one coalesced request does not re-prioritise unrelated requests of the same caller.
|
|
197
|
+
const ref = new PriorityRef(caller?.level ?? 'normal');
|
|
198
|
+
const off = caller ? caller.on(() => ref.raise(caller.level)) : null;
|
|
199
|
+
const p = this.doRequest(req, meta, check, ref, raw).finally(() => { this.inflight.delete(key); off?.(); });
|
|
200
|
+
this.inflight.set(key, { p: p, ref });
|
|
201
|
+
return p;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 429 -> pause (Retry-After, or the default doubling per consecutive 429 on the bucket);
|
|
205
|
+
* `x-ratelimit-remaining: 0` -> pause until reset (capped).
|
|
206
|
+
*/
|
|
207
|
+
respectRateLimitHeaders(res, meta, rateLimited = res.status === 429) {
|
|
208
|
+
const { limiter } = this.opts;
|
|
209
|
+
if (rateLimited) {
|
|
210
|
+
const n = (this.strikes.get(meta.bucket) ?? 0) + 1;
|
|
211
|
+
this.strikes.set(meta.bucket, n);
|
|
212
|
+
const base = this.opts.rateLimitPauseMs ?? 30_000;
|
|
213
|
+
const backoff = Math.min(base * 2 ** (n - 1), MAX_429_PAUSE_MS);
|
|
214
|
+
// The server's own wait (Retry-After, else its x-ratelimit-reset — task-1368) governs the first strike;
|
|
215
|
+
// a repeated 429 on the same bucket never pauses less than the doubling backoff.
|
|
216
|
+
const stated = retryAfterMs(res, this.clock.now());
|
|
217
|
+
limiter.pause(meta.bucket, stated !== undefined ? Math.max(stated, n > 1 ? backoff : 0) : backoff);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
this.strikes.delete(meta.bucket);
|
|
221
|
+
}
|
|
222
|
+
if (res.headers['x-ratelimit-remaining'] === '0') {
|
|
223
|
+
const reset = Number(res.headers['x-ratelimit-reset']); // epoch seconds (Jupiter)
|
|
224
|
+
const until = Number.isFinite(reset) && reset > 0 ? reset * 1000 - this.clock.now() : 10_000;
|
|
225
|
+
limiter.pause(meta.bucket, Math.min(Math.max(until, 1_000), MAX_HEADER_PAUSE_MS));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async doRequest(req, meta, check, priority, raw = false) {
|
|
229
|
+
const { health, limiter, transport } = this.opts;
|
|
230
|
+
const url = req.url;
|
|
231
|
+
if (!health.isAvailable(meta.source))
|
|
232
|
+
throw new SourceUnavailableError(meta.source, health.state(meta.source));
|
|
233
|
+
// Pass the caller's abort to the limiter so a bounded wait is cut even while queued behind a
|
|
234
|
+
// 429 pause (task-1310): the confirm-time quote can never hang past its deadline in the bucket.
|
|
235
|
+
await limiter.acquire(meta.bucket, req.signal, priority);
|
|
236
|
+
let res;
|
|
237
|
+
try {
|
|
238
|
+
res = await transport(req);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const msg = redactSecrets(err instanceof Error ? err.message : String(err));
|
|
242
|
+
health.record(meta.source, { ok: false, error: msg });
|
|
243
|
+
this.opts.onRequest?.(req, meta, { status: null, latencyMs: 0 });
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
this.opts.onRequest?.(req, meta, { status: res.status, latencyMs: res.latencyMs });
|
|
247
|
+
if (res.status < 200 || res.status >= 300) {
|
|
248
|
+
this.respectRateLimitHeaders(res, meta);
|
|
249
|
+
health.record(meta.source, { ok: false, status: res.status, latencyMs: res.latencyMs });
|
|
250
|
+
throw new HttpError(res.status, url, undefined, retryAfterMs(res, this.clock.now()));
|
|
251
|
+
}
|
|
252
|
+
if (raw) {
|
|
253
|
+
// Text document (RSS/XML): no JSON parse, no body check; headers still drive the limiter.
|
|
254
|
+
this.respectRateLimitHeaders(res, meta);
|
|
255
|
+
health.record(meta.source, { ok: true, status: res.status, latencyMs: res.latencyMs });
|
|
256
|
+
return { data: res.body, status: res.status, latencyMs: res.latencyMs, fetchedAt: this.clock.now() };
|
|
257
|
+
}
|
|
258
|
+
let data;
|
|
259
|
+
try {
|
|
260
|
+
data = JSON.parse(res.body);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
this.respectRateLimitHeaders(res, meta);
|
|
264
|
+
health.record(meta.source, { ok: false, status: res.status, latencyMs: res.latencyMs, error: 'invalid JSON' });
|
|
265
|
+
throw new HttpError(res.status, url, `invalid JSON from ${url}`);
|
|
266
|
+
}
|
|
267
|
+
const bad = check?.(data);
|
|
268
|
+
this.respectRateLimitHeaders(res, meta, bad?.status === 429);
|
|
269
|
+
if (bad) {
|
|
270
|
+
const status = bad.status ?? res.status;
|
|
271
|
+
health.record(meta.source, { ok: false, status, latencyMs: res.latencyMs, error: bad.error });
|
|
272
|
+
throw new HttpError(status, url, `${bad.error} (${url})`, retryAfterMs(res, this.clock.now()));
|
|
273
|
+
}
|
|
274
|
+
health.record(meta.source, { ok: true, status: res.status, latencyMs: res.latencyMs });
|
|
275
|
+
return { data, status: res.status, latencyMs: res.latencyMs, fetchedAt: this.clock.now() };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @dexterm/data — on-disk coin-image cache (task-1326). Lives under the app data dir (next to dexterm.db):
|
|
2
|
+
// `<dir>/images/<sha256(url)[0..31]>.png` holds OUR re-encoded thumbnail (never the bytes the host sent),
|
|
3
|
+
// so nothing downstream — the popup page, a future export — ever parses third-party image data. A
|
|
4
|
+
// `.fail` marker remembers a refused/undecodable URL for a while so a dead IPFS link is not retried on
|
|
5
|
+
// every launch. LRU by file mtime: a hit touches the file; when the directory grows past `maxBytes` the
|
|
6
|
+
// oldest files go. Every filesystem failure is swallowed (a cache must never break the app).
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync, utimesSync, renameSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
export function imageCacheKey(url) {
|
|
11
|
+
return createHash('sha256').update(url).digest('hex').slice(0, 32);
|
|
12
|
+
}
|
|
13
|
+
export class ImageDiskCache {
|
|
14
|
+
dir;
|
|
15
|
+
maxBytes;
|
|
16
|
+
failTtlMs;
|
|
17
|
+
now;
|
|
18
|
+
ready = false;
|
|
19
|
+
lastTouch = new Map();
|
|
20
|
+
stats = { files: 0, bytes: 0, hits: 0, misses: 0, writes: 0, evictions: 0 };
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.dir = opts.dir;
|
|
23
|
+
this.maxBytes = opts.maxBytes ?? 32 * 1024 * 1024;
|
|
24
|
+
this.failTtlMs = opts.failTtlMs ?? 6 * 3_600_000;
|
|
25
|
+
this.now = opts.now ?? (() => Date.now());
|
|
26
|
+
}
|
|
27
|
+
ensure() {
|
|
28
|
+
if (this.ready)
|
|
29
|
+
return true;
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
this.ready = true;
|
|
33
|
+
}
|
|
34
|
+
catch { /* unwritable: cache off */ }
|
|
35
|
+
return this.ready;
|
|
36
|
+
}
|
|
37
|
+
pathFor(key, ext) { return join(this.dir, `${key}.${ext}`); }
|
|
38
|
+
/** Our stored thumbnail PNG for a URL key, or null. Touches the file (LRU). */
|
|
39
|
+
get(key) {
|
|
40
|
+
if (!this.ensure())
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const p = this.pathFor(key, 'png');
|
|
44
|
+
const buf = readFileSync(p);
|
|
45
|
+
this.stats.hits += 1;
|
|
46
|
+
const t = this.now();
|
|
47
|
+
if ((this.lastTouch.get(key) ?? 0) + 600_000 < t) {
|
|
48
|
+
this.lastTouch.set(key, t);
|
|
49
|
+
try {
|
|
50
|
+
utimesSync(p, new Date(t), new Date(t));
|
|
51
|
+
}
|
|
52
|
+
catch { /* read-only fs: LRU just degrades */ }
|
|
53
|
+
}
|
|
54
|
+
return buf;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
this.stats.misses += 1;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** True when a failure marker younger than failTtl exists for this key (the marker carries its own timestamp). */
|
|
62
|
+
failed(key) {
|
|
63
|
+
if (!this.ensure())
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
const text = readFileSync(this.pathFor(key, 'fail'), 'utf8');
|
|
67
|
+
const at = Number(text.split('\n')[0]);
|
|
68
|
+
return Number.isFinite(at) && this.now() - at < this.failTtlMs;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
markFailed(key, reason) {
|
|
75
|
+
if (!this.ensure())
|
|
76
|
+
return;
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(this.pathFor(key, 'fail'), `${this.now()}\n${reason.slice(0, 200)}`, { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
/** Store our PNG thumbnail (atomic rename), then evict the oldest files past the byte cap. */
|
|
83
|
+
put(key, png) {
|
|
84
|
+
if (!this.ensure())
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
const p = this.pathFor(key, 'png');
|
|
88
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(tmp, png, { mode: 0o600 });
|
|
90
|
+
renameSync(tmp, p);
|
|
91
|
+
try {
|
|
92
|
+
const t = new Date(this.now());
|
|
93
|
+
utimesSync(p, t, t);
|
|
94
|
+
}
|
|
95
|
+
catch { /* LRU just degrades */ }
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(this.pathFor(key, 'fail'));
|
|
98
|
+
}
|
|
99
|
+
catch { /* no marker */ }
|
|
100
|
+
this.stats.writes += 1;
|
|
101
|
+
this.lastTouch.set(key, this.now());
|
|
102
|
+
this.evict();
|
|
103
|
+
}
|
|
104
|
+
catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
/** Drop oldest files until the directory is under the cap. Also measures the directory. */
|
|
107
|
+
evict() {
|
|
108
|
+
let entries = [];
|
|
109
|
+
try {
|
|
110
|
+
for (const name of readdirSync(this.dir)) {
|
|
111
|
+
if (!name.endsWith('.png') && !name.endsWith('.fail'))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const st = statSync(join(this.dir, name));
|
|
115
|
+
entries.push({ name, size: st.size, mtime: st.mtimeMs });
|
|
116
|
+
}
|
|
117
|
+
catch { /* vanished */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let total = entries.reduce((s, e) => s + e.size, 0);
|
|
124
|
+
this.stats.files = entries.length;
|
|
125
|
+
if (total > this.maxBytes) {
|
|
126
|
+
entries = entries.sort((a, b) => a.mtime - b.mtime);
|
|
127
|
+
for (const e of entries) {
|
|
128
|
+
if (total <= this.maxBytes * 0.9)
|
|
129
|
+
break;
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(join(this.dir, e.name));
|
|
132
|
+
total -= e.size;
|
|
133
|
+
this.stats.evictions += 1;
|
|
134
|
+
this.stats.files -= 1;
|
|
135
|
+
}
|
|
136
|
+
catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.stats.bytes = total;
|
|
140
|
+
}
|
|
141
|
+
}
|