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,310 @@
|
|
|
1
|
+
// @dexterm/data — PNG decoder + encoder (task-1326, coin images). Pure TypeScript on node:zlib, no native
|
|
2
|
+
// code and no third-party decoder: an untrusted coin logo is parsed by bounds-checked JS and can never run
|
|
3
|
+
// anything. Decodes every PNG colour type (grey, RGB, palette, grey+alpha, RGBA) at bit depths 1/2/4/8/16,
|
|
4
|
+
// with tRNS transparency and Adam7 interlace, into straight-alpha RGBA8. Dimension and pixel caps are
|
|
5
|
+
// enforced BEFORE any pixel buffer is allocated. Ancillary chunks (APNG, iCCP, gAMA, …) are ignored.
|
|
6
|
+
import { inflateSync, deflateSync } from 'node:zlib';
|
|
7
|
+
import { DEFAULT_LIMITS, ImageDecodeError, checkDims } from "./types.js";
|
|
8
|
+
const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
9
|
+
export function isPng(b) {
|
|
10
|
+
if (b.length < 8)
|
|
11
|
+
return false;
|
|
12
|
+
for (let i = 0; i < 8; i++)
|
|
13
|
+
if (b[i] !== SIG[i])
|
|
14
|
+
return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
function channelsOf(colorType) {
|
|
18
|
+
switch (colorType) {
|
|
19
|
+
case 0: return 1; // grey
|
|
20
|
+
case 2: return 3; // rgb
|
|
21
|
+
case 3: return 1; // palette index
|
|
22
|
+
case 4: return 2; // grey + alpha
|
|
23
|
+
case 6: return 4; // rgba
|
|
24
|
+
default: throw new ImageDecodeError(`png: unknown colour type ${colorType}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function paeth(a, b, c) {
|
|
28
|
+
const p = a + b - c;
|
|
29
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
30
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
31
|
+
}
|
|
32
|
+
/** Undo the per-scanline filter in place over `rows` scanlines of `stride` bytes (plus a filter byte each). */
|
|
33
|
+
function unfilter(data, off, rows, stride, bpp) {
|
|
34
|
+
const out = new Uint8Array(rows * stride);
|
|
35
|
+
let prevRow = null;
|
|
36
|
+
for (let y = 0; y < rows; y++) {
|
|
37
|
+
const ft = data[off];
|
|
38
|
+
off += 1;
|
|
39
|
+
const row = out.subarray(y * stride, (y + 1) * stride);
|
|
40
|
+
row.set(data.subarray(off, off + stride));
|
|
41
|
+
off += stride;
|
|
42
|
+
switch (ft) {
|
|
43
|
+
case 0: break;
|
|
44
|
+
case 1:
|
|
45
|
+
for (let i = bpp; i < stride; i++)
|
|
46
|
+
row[i] = (row[i] + row[i - bpp]) & 0xff;
|
|
47
|
+
break;
|
|
48
|
+
case 2:
|
|
49
|
+
if (prevRow)
|
|
50
|
+
for (let i = 0; i < stride; i++)
|
|
51
|
+
row[i] = (row[i] + prevRow[i]) & 0xff;
|
|
52
|
+
break;
|
|
53
|
+
case 3:
|
|
54
|
+
for (let i = 0; i < stride; i++) {
|
|
55
|
+
const left = i >= bpp ? row[i - bpp] : 0;
|
|
56
|
+
const up = prevRow ? prevRow[i] : 0;
|
|
57
|
+
row[i] = (row[i] + ((left + up) >> 1)) & 0xff;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case 4:
|
|
61
|
+
for (let i = 0; i < stride; i++) {
|
|
62
|
+
const left = i >= bpp ? row[i - bpp] : 0;
|
|
63
|
+
const up = prevRow ? prevRow[i] : 0;
|
|
64
|
+
const ul = prevRow && i >= bpp ? prevRow[i - bpp] : 0;
|
|
65
|
+
row[i] = (row[i] + paeth(left, up, ul)) & 0xff;
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
default: throw new ImageDecodeError(`png: bad filter type ${ft}`);
|
|
69
|
+
}
|
|
70
|
+
prevRow = row;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Read sample `i` (0-based, across the row) of `depth` bits from a packed scanline, scaled to 0..255. */
|
|
75
|
+
function sampleAt(row, i, depth) {
|
|
76
|
+
if (depth === 8)
|
|
77
|
+
return row[i];
|
|
78
|
+
if (depth === 16)
|
|
79
|
+
return row[i * 2]; // high byte: 16-bit → 8-bit
|
|
80
|
+
const perByte = 8 / depth;
|
|
81
|
+
const byte = row[Math.floor(i / perByte)];
|
|
82
|
+
const shift = 8 - depth * ((i % perByte) + 1);
|
|
83
|
+
const v = (byte >> shift) & ((1 << depth) - 1);
|
|
84
|
+
return depth === 1 ? v * 255 : depth === 2 ? v * 85 : v * 17;
|
|
85
|
+
}
|
|
86
|
+
/** Raw sample value (unscaled) for palette indices. */
|
|
87
|
+
function indexAt(row, i, depth) {
|
|
88
|
+
if (depth === 8)
|
|
89
|
+
return row[i];
|
|
90
|
+
const perByte = 8 / depth;
|
|
91
|
+
const byte = row[Math.floor(i / perByte)];
|
|
92
|
+
const shift = 8 - depth * ((i % perByte) + 1);
|
|
93
|
+
return (byte >> shift) & ((1 << depth) - 1);
|
|
94
|
+
}
|
|
95
|
+
// Adam7: x start, y start, x step, y step
|
|
96
|
+
const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
|
|
97
|
+
export function decodePng(bytes, limits = DEFAULT_LIMITS) {
|
|
98
|
+
if (!isPng(bytes))
|
|
99
|
+
throw new ImageDecodeError('png: bad signature');
|
|
100
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
101
|
+
let pos = 8;
|
|
102
|
+
let hdr = null;
|
|
103
|
+
let palette = null;
|
|
104
|
+
let trns = null;
|
|
105
|
+
const idat = [];
|
|
106
|
+
let idatLen = 0;
|
|
107
|
+
while (pos + 8 <= bytes.length) {
|
|
108
|
+
const len = dv.getUint32(pos);
|
|
109
|
+
const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]);
|
|
110
|
+
const dataOff = pos + 8;
|
|
111
|
+
if (len > bytes.length - dataOff)
|
|
112
|
+
throw new ImageDecodeError(`png: chunk ${type} overruns the file`);
|
|
113
|
+
if (type === 'IHDR') {
|
|
114
|
+
if (len < 13)
|
|
115
|
+
throw new ImageDecodeError('png: short IHDR');
|
|
116
|
+
hdr = { w: dv.getUint32(dataOff), h: dv.getUint32(dataOff + 4), depth: bytes[dataOff + 8], colorType: bytes[dataOff + 9], interlace: bytes[dataOff + 12] };
|
|
117
|
+
if (bytes[dataOff + 10] !== 0 || bytes[dataOff + 11] !== 0)
|
|
118
|
+
throw new ImageDecodeError('png: unsupported compression/filter method');
|
|
119
|
+
if (![1, 2, 4, 8, 16].includes(hdr.depth))
|
|
120
|
+
throw new ImageDecodeError(`png: bad bit depth ${hdr.depth}`);
|
|
121
|
+
if (hdr.interlace !== 0 && hdr.interlace !== 1)
|
|
122
|
+
throw new ImageDecodeError('png: bad interlace method');
|
|
123
|
+
checkDims(hdr.w, hdr.h, limits, 'png');
|
|
124
|
+
}
|
|
125
|
+
else if (type === 'PLTE') {
|
|
126
|
+
palette = bytes.subarray(dataOff, dataOff + len);
|
|
127
|
+
}
|
|
128
|
+
else if (type === 'tRNS') {
|
|
129
|
+
trns = bytes.subarray(dataOff, dataOff + len);
|
|
130
|
+
}
|
|
131
|
+
else if (type === 'IDAT') {
|
|
132
|
+
idat.push(bytes.subarray(dataOff, dataOff + len));
|
|
133
|
+
idatLen += len;
|
|
134
|
+
if (idatLen > limits.maxBytes)
|
|
135
|
+
throw new ImageDecodeError('png: compressed data over the byte cap');
|
|
136
|
+
}
|
|
137
|
+
else if (type === 'IEND') {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
pos = dataOff + len + 4; // skip CRC (not verified: a corrupt chunk fails decoding anyway)
|
|
141
|
+
}
|
|
142
|
+
if (!hdr)
|
|
143
|
+
throw new ImageDecodeError('png: no IHDR');
|
|
144
|
+
if (!idat.length)
|
|
145
|
+
throw new ImageDecodeError('png: no IDAT');
|
|
146
|
+
const { w, h, depth, colorType, interlace } = hdr;
|
|
147
|
+
const ch = channelsOf(colorType);
|
|
148
|
+
if (colorType === 3 && !palette)
|
|
149
|
+
throw new ImageDecodeError('png: palette image without PLTE');
|
|
150
|
+
if ((colorType === 2 || colorType === 4 || colorType === 6) && depth < 8)
|
|
151
|
+
throw new ImageDecodeError('png: bad depth for colour type');
|
|
152
|
+
const bitsPerPx = ch * depth;
|
|
153
|
+
const bpp = Math.max(1, Math.ceil(bitsPerPx / 8));
|
|
154
|
+
const strideFor = (cols) => Math.ceil((cols * bitsPerPx) / 8);
|
|
155
|
+
// Expected raw size (bounded by the dimension caps checked above).
|
|
156
|
+
let expect = 0;
|
|
157
|
+
if (interlace) {
|
|
158
|
+
for (const [sx, sy, dx, dy] of ADAM7) {
|
|
159
|
+
const pw = Math.ceil((w - sx) / dx), ph = Math.ceil((h - sy) / dy);
|
|
160
|
+
if (pw > 0 && ph > 0)
|
|
161
|
+
expect += ph * (strideFor(pw) + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
expect = h * (strideFor(w) + 1);
|
|
166
|
+
const raw = inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c)), idatLen), { maxOutputLength: expect });
|
|
167
|
+
if (raw.length < expect)
|
|
168
|
+
throw new ImageDecodeError('png: truncated image data');
|
|
169
|
+
const out = new Uint8Array(w * h * 4);
|
|
170
|
+
const writePixel = (x, y, row, i) => {
|
|
171
|
+
const o = (y * w + x) * 4;
|
|
172
|
+
switch (colorType) {
|
|
173
|
+
case 0: {
|
|
174
|
+
const g = sampleAt(row, i, depth);
|
|
175
|
+
out[o] = g;
|
|
176
|
+
out[o + 1] = g;
|
|
177
|
+
out[o + 2] = g;
|
|
178
|
+
let a = 255;
|
|
179
|
+
if (trns && trns.length >= 2) {
|
|
180
|
+
const key = (trns[0] << 8) | trns[1];
|
|
181
|
+
const rawV = depth === 16 ? ((row[i * 2] << 8) | row[i * 2 + 1]) : indexAt(row, i, depth);
|
|
182
|
+
if (rawV === key)
|
|
183
|
+
a = 0;
|
|
184
|
+
}
|
|
185
|
+
out[o + 3] = a;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 2: {
|
|
189
|
+
const s = depth === 16 ? 2 : 1;
|
|
190
|
+
const r = row[i * 3 * s], g = row[(i * 3 + 1) * s], b = row[(i * 3 + 2) * s];
|
|
191
|
+
out[o] = r;
|
|
192
|
+
out[o + 1] = g;
|
|
193
|
+
out[o + 2] = b;
|
|
194
|
+
let a = 255;
|
|
195
|
+
if (trns && trns.length >= 6) {
|
|
196
|
+
const kr = (trns[0] << 8) | trns[1], kg = (trns[2] << 8) | trns[3], kb = (trns[4] << 8) | trns[5];
|
|
197
|
+
const rr = depth === 16 ? ((row[i * 6] << 8) | row[i * 6 + 1]) : r;
|
|
198
|
+
const gg = depth === 16 ? ((row[i * 6 + 2] << 8) | row[i * 6 + 3]) : g;
|
|
199
|
+
const bb = depth === 16 ? ((row[i * 6 + 4] << 8) | row[i * 6 + 5]) : b;
|
|
200
|
+
if (rr === kr && gg === kg && bb === kb)
|
|
201
|
+
a = 0;
|
|
202
|
+
}
|
|
203
|
+
out[o + 3] = a;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 3: {
|
|
207
|
+
const idx = indexAt(row, i, depth);
|
|
208
|
+
const p = palette;
|
|
209
|
+
if (idx * 3 + 2 >= p.length)
|
|
210
|
+
throw new ImageDecodeError('png: palette index out of range');
|
|
211
|
+
out[o] = p[idx * 3];
|
|
212
|
+
out[o + 1] = p[idx * 3 + 1];
|
|
213
|
+
out[o + 2] = p[idx * 3 + 2];
|
|
214
|
+
out[o + 3] = trns && idx < trns.length ? trns[idx] : 255;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 4: {
|
|
218
|
+
const s = depth === 16 ? 2 : 1;
|
|
219
|
+
const g = row[i * 2 * s], a = row[(i * 2 + 1) * s];
|
|
220
|
+
out[o] = g;
|
|
221
|
+
out[o + 1] = g;
|
|
222
|
+
out[o + 2] = g;
|
|
223
|
+
out[o + 3] = a;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 6: {
|
|
227
|
+
const s = depth === 16 ? 2 : 1;
|
|
228
|
+
out[o] = row[i * 4 * s];
|
|
229
|
+
out[o + 1] = row[(i * 4 + 1) * s];
|
|
230
|
+
out[o + 2] = row[(i * 4 + 2) * s];
|
|
231
|
+
out[o + 3] = row[(i * 4 + 3) * s];
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
if (!interlace) {
|
|
237
|
+
const stride = strideFor(w);
|
|
238
|
+
const px = unfilter(raw, 0, h, stride, bpp);
|
|
239
|
+
for (let y = 0; y < h; y++) {
|
|
240
|
+
const row = px.subarray(y * stride, (y + 1) * stride);
|
|
241
|
+
for (let x = 0; x < w; x++)
|
|
242
|
+
writePixel(x, y, row, x);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
let off = 0;
|
|
247
|
+
for (const [sx, sy, dx, dy] of ADAM7) {
|
|
248
|
+
const pw = Math.ceil((w - sx) / dx), ph = Math.ceil((h - sy) / dy);
|
|
249
|
+
if (pw <= 0 || ph <= 0)
|
|
250
|
+
continue;
|
|
251
|
+
const stride = strideFor(pw);
|
|
252
|
+
const px = unfilter(raw, off, ph, stride, bpp);
|
|
253
|
+
off += ph * (stride + 1);
|
|
254
|
+
for (let py = 0; py < ph; py++) {
|
|
255
|
+
const row = px.subarray(py * stride, (py + 1) * stride);
|
|
256
|
+
for (let pxi = 0; pxi < pw; pxi++)
|
|
257
|
+
writePixel(sx + pxi * dx, sy + py * dy, row, pxi);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { w, h, rgba: out };
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------------------------- encoder
|
|
264
|
+
const CRC_TABLE = (() => {
|
|
265
|
+
const t = new Uint32Array(256);
|
|
266
|
+
for (let n = 0; n < 256; n++) {
|
|
267
|
+
let c = n;
|
|
268
|
+
for (let k = 0; k < 8; k++)
|
|
269
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
270
|
+
t[n] = c >>> 0;
|
|
271
|
+
}
|
|
272
|
+
return t;
|
|
273
|
+
})();
|
|
274
|
+
function crc32(buf) {
|
|
275
|
+
let c = 0xffffffff;
|
|
276
|
+
for (let i = 0; i < buf.length; i++)
|
|
277
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
278
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
279
|
+
}
|
|
280
|
+
function chunk(type, data) {
|
|
281
|
+
const out = Buffer.alloc(12 + data.length);
|
|
282
|
+
out.writeUInt32BE(data.length, 0);
|
|
283
|
+
out.write(type, 4, 'latin1');
|
|
284
|
+
out.set(data, 8);
|
|
285
|
+
const crc = crc32(out.subarray(4, 8 + data.length));
|
|
286
|
+
out.writeUInt32BE(crc, 8 + data.length);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/** Encode straight-alpha RGBA8 as a PNG (colour type 6, filter 0 on every scanline, deflate level 6). */
|
|
290
|
+
export function encodePng(img) {
|
|
291
|
+
const { w, h, rgba } = img;
|
|
292
|
+
if (rgba.length !== w * h * 4)
|
|
293
|
+
throw new ImageDecodeError('png encode: buffer size mismatch');
|
|
294
|
+
const ihdr = Buffer.alloc(13);
|
|
295
|
+
ihdr.writeUInt32BE(w, 0);
|
|
296
|
+
ihdr.writeUInt32BE(h, 4);
|
|
297
|
+
ihdr[8] = 8;
|
|
298
|
+
ihdr[9] = 6;
|
|
299
|
+
ihdr[10] = 0;
|
|
300
|
+
ihdr[11] = 0;
|
|
301
|
+
ihdr[12] = 0;
|
|
302
|
+
const stride = w * 4;
|
|
303
|
+
const raw = Buffer.alloc(h * (stride + 1));
|
|
304
|
+
for (let y = 0; y < h; y++) {
|
|
305
|
+
raw[y * (stride + 1)] = 0;
|
|
306
|
+
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
307
|
+
}
|
|
308
|
+
const idat = deflateSync(raw, { level: 6 });
|
|
309
|
+
return Buffer.concat([Buffer.from(SIG), chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0))]);
|
|
310
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// @dexterm/data — ImageService (task-1326): the one object the UI asks for coin images. `get(url)` is
|
|
2
|
+
// SYNCHRONOUS and never blocks: it answers from memory and, for an unknown URL, enqueues the work and
|
|
3
|
+
// answers `pending`. A small worker pool then (1) checks the on-disk cache, (2) fetches through the SSRF-
|
|
4
|
+
// safe binary fetch, (3) decodes + thumbnails in a worker thread, (4) stores our re-encoded PNG, and
|
|
5
|
+
// (5) calls `onChange` so the UI loop redraws. Priorities: the screen re-requests what is visible every
|
|
6
|
+
// frame, so the queue is served highest-priority-then-most-recent (the rows you are looking at win over
|
|
7
|
+
// a pool scrolled past). Offline / replay runs answer from disk only and never touch the network.
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { decodePng } from "./png.js";
|
|
11
|
+
import { fetchBytes, thumbnailUrl } from "./fetch.js";
|
|
12
|
+
import { ImageDiskCache, imageCacheKey } from "./cache.js";
|
|
13
|
+
import { runDecodeJob } from "./decode-worker.js";
|
|
14
|
+
/** Backoff before a transient failure is retried: 2 min, then 10 min, then never (this session). */
|
|
15
|
+
export const RETRY_BACKOFF_MS = [120_000, 600_000];
|
|
16
|
+
export class ImageService {
|
|
17
|
+
mem = new Map();
|
|
18
|
+
queue = [];
|
|
19
|
+
inflightUrls = new Set();
|
|
20
|
+
perHostInflight = new Map();
|
|
21
|
+
disk;
|
|
22
|
+
opts;
|
|
23
|
+
seq = 0;
|
|
24
|
+
closed = false;
|
|
25
|
+
worker = null;
|
|
26
|
+
workerBroken = false;
|
|
27
|
+
jobId = 0;
|
|
28
|
+
jobs = new Map();
|
|
29
|
+
abort = new AbortController();
|
|
30
|
+
/** Called after any image settles (ready or missing); the UI loop redraws. Settable after construction. */
|
|
31
|
+
onChange = null;
|
|
32
|
+
idleWaiters = [];
|
|
33
|
+
stats = { known: 0, ready: 0, missing: 0, pending: 0, inflight: 0, queued: 0, fetched: 0, fetchFailed: 0, diskHits: 0, decodeFailed: 0, decodeMsTotal: 0, decodes: 0, workerFallbacks: 0, bytesFetched: 0 };
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.opts = { offline: false, concurrency: 3, perHost: 2, thumbMax: 96, maxMemory: 400, ...opts };
|
|
36
|
+
this.disk = new ImageDiskCache({ dir: opts.dir, maxBytes: opts.maxDiskBytes, now: opts.now });
|
|
37
|
+
this.onChange = opts.onChange ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** True when nothing is queued or in flight. */
|
|
40
|
+
get idle() { return this.queue.length === 0 && this.inflightUrls.size === 0; }
|
|
41
|
+
/** Resolves once the queue drains (every requested image settled) or after `timeoutMs`. One-shot renders use it. */
|
|
42
|
+
whenIdle(timeoutMs = 4000) {
|
|
43
|
+
if (this.idle)
|
|
44
|
+
return Promise.resolve(true);
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const timer = setTimeout(() => { this.idleWaiters = this.idleWaiters.filter((w) => w !== done); resolve(false); }, timeoutMs);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
const done = () => { clearTimeout(timer); resolve(true); };
|
|
49
|
+
this.idleWaiters.push(done);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
get dir() { return this.disk.dir; }
|
|
53
|
+
get offline() { return this.opts.offline; }
|
|
54
|
+
/** Stable cache key for a source URL (the popup route and the disk file name use it). */
|
|
55
|
+
keyFor(url) { return imageCacheKey(url); }
|
|
56
|
+
/**
|
|
57
|
+
* The image for a URL, right now. Never blocks: unknown URLs are queued (bumping their priority /
|
|
58
|
+
* recency on every ask) and answered `pending` until the worker lands them.
|
|
59
|
+
*/
|
|
60
|
+
get(url, priority = 0) {
|
|
61
|
+
if (!url || typeof url !== 'string')
|
|
62
|
+
return { state: 'none' };
|
|
63
|
+
const e = this.mem.get(url);
|
|
64
|
+
if (e) {
|
|
65
|
+
if (e.state.state === 'pending' && !this.inflightUrls.has(url)) {
|
|
66
|
+
e.priority = Math.max(e.priority, priority);
|
|
67
|
+
e.seq = ++this.seq;
|
|
68
|
+
}
|
|
69
|
+
else if (e.state.state === 'ready') {
|
|
70
|
+
this.mem.delete(url);
|
|
71
|
+
this.mem.set(url, e);
|
|
72
|
+
} // LRU refresh
|
|
73
|
+
else if (e.state.state === 'missing' && e.state.retryAt !== undefined && this.now() >= e.state.retryAt && !this.inflightUrls.has(url)) {
|
|
74
|
+
// a transient failure, past its backoff: ask again (bounded by RETRY_BACKOFF_MS.length attempts)
|
|
75
|
+
e.state = { state: 'pending', key: e.key };
|
|
76
|
+
e.priority = priority;
|
|
77
|
+
e.seq = ++this.seq;
|
|
78
|
+
this.queue.push(e);
|
|
79
|
+
this.pump();
|
|
80
|
+
}
|
|
81
|
+
return e.state;
|
|
82
|
+
}
|
|
83
|
+
const key = this.keyFor(url);
|
|
84
|
+
const entry = { state: { state: 'pending', key }, url, key, priority, seq: ++this.seq, at: this.now(), attempts: 0 };
|
|
85
|
+
this.mem.set(url, entry);
|
|
86
|
+
this.queue.push(entry);
|
|
87
|
+
this.stats.known = this.mem.size;
|
|
88
|
+
this.pump();
|
|
89
|
+
return entry.state;
|
|
90
|
+
}
|
|
91
|
+
/** Warm a set of URLs (visible rows, the open token) at a priority; no answer needed. */
|
|
92
|
+
prefetch(urls, priority = 0) {
|
|
93
|
+
for (const u of urls)
|
|
94
|
+
if (u)
|
|
95
|
+
this.get(u, priority);
|
|
96
|
+
}
|
|
97
|
+
/** Our stored PNG for a key (memory, then disk) — what the popup server serves. Sync, small. */
|
|
98
|
+
pngFor(key) {
|
|
99
|
+
if (!/^[0-9a-f]{32}$/.test(key))
|
|
100
|
+
return null;
|
|
101
|
+
for (const e of this.mem.values())
|
|
102
|
+
if (e.key === key && e.state.state === 'ready')
|
|
103
|
+
return e.state.png;
|
|
104
|
+
const b = this.disk.get(key);
|
|
105
|
+
return b ? new Uint8Array(b.buffer, b.byteOffset, b.byteLength) : null;
|
|
106
|
+
}
|
|
107
|
+
snapshot() {
|
|
108
|
+
let ready = 0, missing = 0, pending = 0;
|
|
109
|
+
for (const e of this.mem.values()) {
|
|
110
|
+
if (e.state.state === 'ready')
|
|
111
|
+
ready++;
|
|
112
|
+
else if (e.state.state === 'missing')
|
|
113
|
+
missing++;
|
|
114
|
+
else
|
|
115
|
+
pending++;
|
|
116
|
+
}
|
|
117
|
+
return { ...this.stats, known: this.mem.size, ready, missing, pending, inflight: this.inflightUrls.size, queued: this.queue.length };
|
|
118
|
+
}
|
|
119
|
+
close() {
|
|
120
|
+
if (this.closed)
|
|
121
|
+
return;
|
|
122
|
+
this.closed = true;
|
|
123
|
+
this.abort.abort(new Error('closing'));
|
|
124
|
+
this.queue.length = 0;
|
|
125
|
+
for (const j of this.jobs.values()) {
|
|
126
|
+
clearTimeout(j.timer);
|
|
127
|
+
j.resolve({ id: 0, ok: false, error: 'closing' });
|
|
128
|
+
}
|
|
129
|
+
this.jobs.clear();
|
|
130
|
+
if (this.worker) {
|
|
131
|
+
void this.worker.terminate().catch(() => { });
|
|
132
|
+
this.worker = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
now() { return this.opts.now ? this.opts.now() : Date.now(); }
|
|
136
|
+
hostOf(url) { try {
|
|
137
|
+
return new URL(url).hostname;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return '?';
|
|
141
|
+
} }
|
|
142
|
+
pump() {
|
|
143
|
+
if (this.closed)
|
|
144
|
+
return;
|
|
145
|
+
while (this.inflightUrls.size < this.opts.concurrency && this.queue.length) {
|
|
146
|
+
// highest priority, then most recently asked for
|
|
147
|
+
let best = -1;
|
|
148
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
149
|
+
const e = this.queue[i];
|
|
150
|
+
const host = this.hostOf(e.url);
|
|
151
|
+
if ((this.perHostInflight.get(host) ?? 0) >= this.opts.perHost)
|
|
152
|
+
continue;
|
|
153
|
+
if (best < 0 || e.priority > this.queue[best].priority || (e.priority === this.queue[best].priority && e.seq > this.queue[best].seq))
|
|
154
|
+
best = i;
|
|
155
|
+
}
|
|
156
|
+
if (best < 0)
|
|
157
|
+
return;
|
|
158
|
+
const [e] = this.queue.splice(best, 1);
|
|
159
|
+
void this.run(e);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
settle(e, state) {
|
|
163
|
+
e.state = state;
|
|
164
|
+
e.at = this.now();
|
|
165
|
+
if (state.state === 'ready') {
|
|
166
|
+
this.mem.delete(e.url);
|
|
167
|
+
this.mem.set(e.url, e);
|
|
168
|
+
// memory LRU: drop the oldest READY entries past the cap (pending/missing are tiny)
|
|
169
|
+
let readyCount = 0;
|
|
170
|
+
for (const x of this.mem.values())
|
|
171
|
+
if (x.state.state === 'ready')
|
|
172
|
+
readyCount++;
|
|
173
|
+
if (readyCount > this.opts.maxMemory) {
|
|
174
|
+
for (const [k, x] of this.mem) {
|
|
175
|
+
if (x.state.state !== 'ready')
|
|
176
|
+
continue;
|
|
177
|
+
this.mem.delete(k);
|
|
178
|
+
readyCount--;
|
|
179
|
+
if (readyCount <= this.opts.maxMemory)
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!this.closed)
|
|
185
|
+
this.onChange?.();
|
|
186
|
+
}
|
|
187
|
+
async run(e) {
|
|
188
|
+
const host = this.hostOf(e.url);
|
|
189
|
+
this.inflightUrls.add(e.url);
|
|
190
|
+
this.perHostInflight.set(host, (this.perHostInflight.get(host) ?? 0) + 1);
|
|
191
|
+
const t0 = this.now();
|
|
192
|
+
try {
|
|
193
|
+
// 1. disk cache (our own PNG) — fresh or not, it is the thumbnail we last made for this URL
|
|
194
|
+
const cached = this.disk.get(e.key);
|
|
195
|
+
if (cached) {
|
|
196
|
+
try {
|
|
197
|
+
const img = decodePng(cached);
|
|
198
|
+
this.stats.diskHits += 1;
|
|
199
|
+
this.opts.onRequest?.({ url: e.url, ok: true, bytes: cached.length, ms: this.now() - t0, reason: null, source: 'disk' });
|
|
200
|
+
this.settle(e, { state: 'ready', img, png: new Uint8Array(cached.buffer, cached.byteOffset, cached.byteLength), key: e.key });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
catch { /* corrupt file: fall through and refetch */ }
|
|
204
|
+
}
|
|
205
|
+
if (this.disk.failed(e.key)) {
|
|
206
|
+
this.settle(e, { state: 'missing', reason: 'failed recently', key: e.key });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (this.opts.offline) {
|
|
210
|
+
this.opts.onRequest?.({ url: e.url, ok: false, bytes: 0, ms: 0, reason: 'offline', source: 'offline' });
|
|
211
|
+
this.settle(e, { state: 'missing', reason: 'offline', key: e.key });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// 2. network
|
|
215
|
+
const target = (this.opts.rewrite ?? thumbnailUrl)(e.url);
|
|
216
|
+
const res = this.opts.fetch ? await this.opts.fetch(target, this.abort.signal) : await fetchBytes(target, { signal: this.abort.signal });
|
|
217
|
+
if (this.closed)
|
|
218
|
+
return;
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
this.stats.fetchFailed += 1;
|
|
221
|
+
this.opts.onRequest?.({ url: target, ok: false, bytes: 0, ms: res.elapsedMs, reason: res.reason, source: 'network' });
|
|
222
|
+
// a 4xx (not 429) or a policy refusal is durable (marked on disk); a 429 / timeout / 5xx is transient and
|
|
223
|
+
// retried after a backoff within this session (public IPFS gateways rate-limit in bursts)
|
|
224
|
+
const policy = res.status === 0 && !/timed out|fetch failed/.test(res.reason ?? '');
|
|
225
|
+
const durable = policy || (res.status >= 400 && res.status < 500 && res.status !== 429);
|
|
226
|
+
if (durable)
|
|
227
|
+
this.disk.markFailed(e.key, res.reason ?? 'http');
|
|
228
|
+
e.attempts += 1;
|
|
229
|
+
const wait = durable ? undefined : RETRY_BACKOFF_MS[e.attempts - 1];
|
|
230
|
+
this.settle(e, { state: 'missing', reason: res.reason ?? 'fetch failed', key: e.key, ...(wait !== undefined ? { retryAt: this.now() + wait } : {}) });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.stats.fetched += 1;
|
|
234
|
+
this.stats.bytesFetched += res.bytes.length;
|
|
235
|
+
// 3. decode (worker thread), 4. store, 5. notify
|
|
236
|
+
const td = this.now();
|
|
237
|
+
const d = await this.decode(res.bytes);
|
|
238
|
+
this.stats.decodes += 1;
|
|
239
|
+
this.stats.decodeMsTotal += this.now() - td;
|
|
240
|
+
if (this.closed)
|
|
241
|
+
return;
|
|
242
|
+
if (!d.ok) {
|
|
243
|
+
this.stats.decodeFailed += 1;
|
|
244
|
+
this.opts.onRequest?.({ url: target, ok: false, bytes: res.bytes.length, ms: this.now() - t0, reason: d.error, source: 'network' });
|
|
245
|
+
this.disk.markFailed(e.key, d.error);
|
|
246
|
+
this.settle(e, { state: 'missing', reason: d.error, key: e.key });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.disk.put(e.key, d.png);
|
|
250
|
+
this.opts.onRequest?.({ url: target, ok: true, bytes: res.bytes.length, ms: this.now() - t0, reason: null, source: 'network' });
|
|
251
|
+
this.settle(e, { state: 'ready', img: { w: d.w, h: d.h, rgba: d.rgba }, png: d.png, key: e.key });
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (!this.closed)
|
|
255
|
+
this.settle(e, { state: 'missing', reason: err.message ?? 'error', key: e.key });
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
this.inflightUrls.delete(e.url);
|
|
259
|
+
this.perHostInflight.set(host, Math.max(0, (this.perHostInflight.get(host) ?? 1) - 1));
|
|
260
|
+
this.pump();
|
|
261
|
+
if (this.idle && this.idleWaiters.length) {
|
|
262
|
+
const ws = this.idleWaiters;
|
|
263
|
+
this.idleWaiters = [];
|
|
264
|
+
for (const w of ws)
|
|
265
|
+
w();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
decode(bytes) {
|
|
270
|
+
if (this.opts.decode)
|
|
271
|
+
return this.opts.decode(bytes, this.opts.thumbMax);
|
|
272
|
+
const w = this.ensureWorker();
|
|
273
|
+
const id = ++this.jobId;
|
|
274
|
+
const job = { id, bytes: new Uint8Array(bytes), thumbMax: this.opts.thumbMax };
|
|
275
|
+
if (!w) {
|
|
276
|
+
this.stats.workerFallbacks += 1;
|
|
277
|
+
return Promise.resolve(runDecodeJob(job));
|
|
278
|
+
}
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
// A decode that takes more than 10 s is pathological input: kill the worker, fail the job, rebuild lazily.
|
|
281
|
+
const timer = setTimeout(() => { this.jobs.delete(id); this.breakWorker(); resolve({ id, ok: false, error: 'decode timed out' }); }, 10_000);
|
|
282
|
+
timer.unref?.();
|
|
283
|
+
this.jobs.set(id, { resolve, timer });
|
|
284
|
+
try {
|
|
285
|
+
w.postMessage(job, [job.bytes.buffer]);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
this.jobs.delete(id);
|
|
290
|
+
resolve({ id, ok: false, error: e.message });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
ensureWorker() {
|
|
295
|
+
if (this.worker)
|
|
296
|
+
return this.worker;
|
|
297
|
+
if (this.workerBroken || this.closed)
|
|
298
|
+
return null;
|
|
299
|
+
try {
|
|
300
|
+
const w = new Worker(new URL('./decode-worker.js', import.meta.url));
|
|
301
|
+
w.unref(); // a worker alone never keeps the process alive (quit stays instant)
|
|
302
|
+
w.on('message', (r) => {
|
|
303
|
+
const j = this.jobs.get(r.id);
|
|
304
|
+
if (!j)
|
|
305
|
+
return;
|
|
306
|
+
clearTimeout(j.timer);
|
|
307
|
+
this.jobs.delete(r.id);
|
|
308
|
+
j.resolve(r);
|
|
309
|
+
});
|
|
310
|
+
w.on('error', (e) => { this.failJobs(`worker error: ${e.message}`); this.breakWorker(); });
|
|
311
|
+
w.on('exit', () => { if (this.worker === w)
|
|
312
|
+
this.worker = null; this.failJobs('worker exited'); });
|
|
313
|
+
this.worker = w;
|
|
314
|
+
return w;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
this.workerBroken = true; // main-thread fallback from now on
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
failJobs(reason) {
|
|
322
|
+
for (const [id, j] of this.jobs) {
|
|
323
|
+
clearTimeout(j.timer);
|
|
324
|
+
j.resolve({ id, ok: false, error: reason });
|
|
325
|
+
}
|
|
326
|
+
this.jobs.clear();
|
|
327
|
+
}
|
|
328
|
+
breakWorker() {
|
|
329
|
+
const w = this.worker;
|
|
330
|
+
this.worker = null;
|
|
331
|
+
if (w)
|
|
332
|
+
void w.terminate().catch(() => { });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @dexterm/data — shared image types + caps (task-1326). Every decoder checks the header against these
|
|
2
|
+
// limits BEFORE allocating a pixel buffer, so a hostile header (4 billion × 4 billion) costs nothing.
|
|
3
|
+
/** 2048², 4 MP, 2 MiB: comfortably above any real coin logo, far below anything that could hurt. */
|
|
4
|
+
export const DEFAULT_LIMITS = { maxDim: 2048, maxPixels: 4 * 1024 * 1024, maxBytes: 2 * 1024 * 1024 };
|
|
5
|
+
export class ImageDecodeError extends Error {
|
|
6
|
+
constructor(msg) { super(msg); this.name = 'ImageDecodeError'; }
|
|
7
|
+
}
|
|
8
|
+
export function checkDims(w, h, limits, what) {
|
|
9
|
+
if (!(w > 0 && h > 0))
|
|
10
|
+
throw new ImageDecodeError(`${what}: empty image`);
|
|
11
|
+
if (w > limits.maxDim || h > limits.maxDim)
|
|
12
|
+
throw new ImageDecodeError(`${what}: ${w}×${h} exceeds the ${limits.maxDim} px dimension cap`);
|
|
13
|
+
if (w * h > limits.maxPixels)
|
|
14
|
+
throw new ImageDecodeError(`${what}: ${w}×${h} exceeds the pixel cap`);
|
|
15
|
+
}
|
|
16
|
+
/** Sniff the container by magic bytes only (never by the URL or the Content-Type header). */
|
|
17
|
+
export function sniffFormat(b) {
|
|
18
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a)
|
|
19
|
+
return 'png';
|
|
20
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
21
|
+
return 'jpeg';
|
|
22
|
+
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61)
|
|
23
|
+
return 'gif';
|
|
24
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50)
|
|
25
|
+
return 'webp';
|
|
26
|
+
return null;
|
|
27
|
+
}
|