fix(classifieds): use npm + bundler instead of CDN, fix v7/v8 wallet API mismatch#693
fix(classifieds): use npm + bundler instead of CDN, fix v7/v8 wallet API mismatch#693biwasxyz wants to merge 5 commits into
Conversation
… + esbuild
The browser-side wallet flow on /classifieds/ needs the real Stacks libraries
to drive Leather/Xverse and watch the on-chain tx. Pulling them from npm
instead of a CDN gives us:
* the modern @stacks/connect@8 API surface (`connect()`, `request()`)
so the source can use the same idioms as a Next.js app
* deterministic versions captured in package-lock.json
* one fewer point of failure (the previous CDN path occasionally lost
a deep dep at load time and the page surfaced "Couldn't load wallet
support")
esbuild is added as a devDependency so the new src-frontend/ source files
can be bundled into a single browser-loadable ESM file (see the next two
commits for the bundler script and wrangler hook). The bundle never
touches the Worker — it lands in public/ and ships with static assets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks src-frontend/*.ts entrypoints with esbuild and emits self-contained ESM bundles into public/. Same operation Next.js / Vite do under the hood — we just have to invoke it ourselves because this repo serves plain HTML out of public/ with no framework in front. Output is gitignored so 1.4 MB of minified JS does not pollute git history every time a Stacks dep is bumped. The next commit wires the build into wrangler so deploy and dev re-run it automatically; nobody runs the script by hand. For now the only entrypoint is classifieds-wallet.ts → wallet-flow.bundle.js; adding more is a one-line array push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes in tandem:
* wrangler.jsonc gains a `build.command` of
`node scripts/build-frontend.mjs`
which wrangler runs before every `dev` and `deploy`. CI does
`npx wrangler deploy --env production` directly (see
.github/workflows/deploy.yml) and that path picks up the same hook,
so no CI changes are needed.
* package.json gains `predev` / `predeploy` npm hooks that fire the
same build step. Necessary only for the local
`npm run dev` / `npm run deploy` flow, where wrangler also reruns
its own build (harmless — esbuild caches and finishes in <200ms).
Net effect: anyone who clones this repo, runs `npm ci`, then runs any
deploy or dev command gets the wallet-flow bundle in
public/classifieds/ automatically. There is no "did you forget to
build?" footgun.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onditions
Replaces the inline esm.sh CDN code that lived in
public/classifieds/index.html with a real TypeScript module under
src-frontend/. Source uses normal `import` statements:
import { connect, request, getLocalStorage } from "@stacks/connect";
import { Pc, PostConditionMode } from "@stacks/transactions";
import { connectWebSocketClient } from "@stacks/blockchain-api-client";
Two real bugs in the previous version, both fixed here:
1. v7 vs v8 mismatch. The previous code targeted @stacks/connect@8's
`connect()` and `request()` Promise API but pinned @stacks/connect@7
on the CDN, which has neither — only the legacy `showConnect()` +
callback flow. The catch block then mapped the resulting "connect
is not a function" throw to a misleading "Wallet connection
cancelled" message. Bumping to @stacks/connect@^8 makes the API
surface the source has always written against actually exist.
2. Post-conditions were a hand-rolled object literal. Wallets reject
unsupported post-condition shapes silently. Replaced with the
`Pc` builder from @stacks/transactions:
Pc.principal(senderAddress)
.willSendEq(CLASSIFIED_PRICE_SATS)
.ft(SBTC_CONTRACT_FULL, SBTC_ASSET_NAME)
This produces a valid SIP-010 fungible post-condition that caps
the transfer at exactly 3,000 sats — the wallet shows that cap
to the user before signing, and the network rejects the tx if
the contract attempts to move more.
Other improvements riding along:
* Switched from `request("stx_callContract", ...)` to the dedicated
`request("stx_transferSip10Ft", ...)` method, which is the
SIP-030 native FT-transfer flow. Wallets render a clearer prompt
than they would for a generic contract call.
* `MutationObserver` waits for `#post-listing-btn` before binding so
we don't race against the inline grid-rendering code that creates
the button after the API response lands.
* Skips binding entirely on the detail view (`?id=...`).
* Exposes `window.__aibtcClassifieds = { disconnect, isConnected,
clearPending }` as opt-in console helpers for debugging stuck
connections.
The bundle is built by scripts/build-frontend.mjs and lands in
public/classifieds/wallet-flow.bundle.js (gitignored). Wired into
wrangler's build hook in the prior commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the ~540 lines of inline JS that tried to dynamic-import @stacks/connect@7 + transactions + blockchain-api-client from esm.sh on first click, and replaces them with a single `<script type="module" src="/classifieds/wallet-flow.bundle.js" defer>` tag near the existing /shared.js include. The bundle is produced by the new src-frontend/classifieds-wallet.ts source via esbuild. It uses normal npm imports (no CDN), the modern @stacks/connect@8 API, and `Pc` post-conditions. See the prior commit for the rewrite details and the bugs it fixes. The grid-rendering code (cardHTML, render, initGrid, initDetail) and all classifieds-listing logic stays inline — only the wallet-modal state machine moved out. The bundle binds itself via MutationObserver once `#post-listing-btn` appears in the DOM, so the inline `applyData` no longer needs to call `bindCompose()`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
agent-news | 017b6e7 | Apr 30 2026, 03:09 PM |
|
Preview deployed: https://agent-news-staging.hosting-962.workers.dev This preview uses sample data — beats, signals, and streaks are seeded automatically. |
arc0btc
left a comment
There was a problem hiding this comment.
Replaces the broken CDN-based @stacks/connect@7 code with a proper npm + esbuild pipeline and a v8-compatible TypeScript module — good fix for two real bugs that were making the classified modal fail silently.
What works well:
- Root cause diagnosis is accurate. The v7 CDN pin against v8 API surface is exactly the kind of silent breakage that shows up as a misleading user error, and the fix is correct.
- The
Pc.principal().willSendEq().ft()post-condition is the right way to construct an FT post-condition. The hand-rolled object literal in the old code was indeed wallet-reject prone. postConditionMode: "deny"+ explicit post-condition is a solid defense-in-depth: even if the wallet auto-generates one, this caps the transfer at exactly 3,000 sats at the network level.- Dual tracking (WebSocket primary + REST polling fallback) is the right architecture for tx confirmation. The POLL_MAX_MS = 4 min covers 24 Stacks blocks with a clean timeout message.
- Resume-across-reloads via localStorage with a 1-hour expiry is thoughtful UX. The
submitToServerretry with graded delays handles indexing lag well. - MutationObserver for waiting on
#post-listing-btnis the correct pattern here — no race against the async grid render. - Build pipeline is clean: wrangler
build.command+ npm pre-hooks, bundle gitignored, esbuild in <200ms. CI picks up the hook without any workflow changes.
[question] Post-conditions on stx_transferSip10Ft (src-frontend/classifieds-wallet.ts)
Passing explicit postConditions + postConditionMode: "deny" alongside stx_transferSip10Ft is valid intent, but the SIP-030 FT transfer method is expected to generate its own post-condition internally. If the wallet (Leather/Xverse) merges them, the user sees one cap; if it lists both, the user sees a duplicate condition. Worth a quick smoke test in Leather's staging mode to confirm the wallet prompt renders cleanly (single post-condition, labeled "Send 3000 sats sBTC"). If it's redundant, removing the explicit postConditions from the FT-transfer call simplifies the code and lets the wallet handle it idiomatically — the network enforces the cap regardless.
[suggestion] Serial entrypoints in build script (scripts/build-frontend.mjs:28)
The for...of loop awaits each entry serially. One entry now, but as the src-frontend/ directory grows this becomes a bottleneck. Parallel is a one-line change:
await Promise.all(entries.map(({ in: entryIn, out }) => {
mkdirSync(dirname(out), { recursive: true });
return build({
entryPoints: [entryIn],
bundle: true,
format: "esm",
target: "es2022",
platform: "browser",
outfile: out,
minify: true,
sourcemap: false,
legalComments: "none",
logLevel: "info",
define: {
"process.env.NODE_ENV": '"production"',
"global": "globalThis",
},
});
}));
[nit] Duplicate blank line in wrangler.jsonc
Two empty lines between the closing brace of "build" and "preview_urls". Single blank line is enough.
[nit] SBTC_CONTRACT_NAME and SBTC_ASSET_NAME are the same string
Both are "sbtc-token". If the sBTC contract ever changes the asset name independently of the contract name, having two constants is correct — but a brief comment would clarify the distinction for a future reader.
Code quality notes:
- The
$and `` helpers are a clean micro-DOM-utility pattern. The strict-throw variant for required elements and null-return for optional ones is exactly the right split. validateFormreturns astring | nullerror — clean functional approach, no exceptions for validation.- The
setStatus/setError/setDonetrio is a clear state-machine output layer. Easy to follow. - The
window.__aibtcClassifiedsdebug export with a TypeScriptdeclare globalambient augmentation is well-done — opt-in, typed, won't pollute production state.
Operational note: We poll the Hiro API in our own sensors and occasionally see elevated error rates on /extended/v1/tx/. The catch { /* swallow */ } in the poll tick handles that correctly — the error is not surfaced to the user, and the tick retries on the next interval. The 4-minute ceiling is well above any transient Hiro outage we've observed.
Why this PR
After #682 merged, the new "Post a Classified" modal failed in two ways:
Both came from the same root cause: I had pinned
@stacks/connect@7.10.1on esm.sh but written the source against@stacks/connect@8. The v8 API (connect(),request()) does not exist in v7, so callinglibs.connect()threw "connect is not a function" and my catch block mapped that to the misleading "cancelled" copy. The intermittent load failures came from esm.sh's deep dep tree (@stacks/auth,@stacks/common,@stacks/transactions,@stacks/connect-ui,jsontokens, …) flaking under cache misses.This PR replaces the CDN approach with a real npm + bundler setup — the same thing Next.js / Vite does, just done explicitly because this repo serves plain HTML out of
public/with no framework in front.What changed
Build pipeline (new, ~15 lines of glue)
package.jsonadds@stacks/connect@^8,@stacks/transactions@^7,@stacks/blockchain-api-client@^8as runtime deps andesbuildas a devDep.scripts/build-frontend.mjs(new, ~30 lines) walkssrc-frontend/*.tsentrypoints with esbuild and emits self-contained ESM bundles intopublic/.wrangler.jsoncgains abuild.command. CI runsnpx wrangler deploydirectly, and that path picks up the hook — no CI changes needed.package.jsonalso addspredev/predeployhooks for the localnpm run …flow.public/classifieds/wallet-flow.bundle.jsis gitignored.Source rewrite
src-frontend/classifieds-wallet.ts(new) — the entire wallet flow as a normal TypeScript module with normalimportstatements. Uses:@stacks/connect@8—connect(),request("stx_transferSip10Ft", …),getLocalStorage(),disconnect(),isConnected(). The Promise API the source has always been written against.Pc:stx_transferSip10Ftinstead of a hand-rolledstx_callContract. Wallet UX is clearer — Leather/Xverse render a "send sBTC" prompt instead of "call contract".#post-listing-btnappears in the DOM, so we don't race the inline grid-rendering code.window.__aibtcClassifiedsdebug helpers (disconnect,isConnected,clearPending) for stuck-connection triage from the browser console.public/classifieds/index.htmldrops 540 lines of inline JS and replaces them with one<script type="module" src="/classifieds/wallet-flow.bundle.js" defer>tag. The grid-rendering code stays inline.Verification
Bundle is ~252 KB gzipped, loaded once per
/classifieds/page (<script type="module" defer>), cached browser-side after first hit. No effect on other pages —/,/wire/,/agents/, etc. don't import it.Test plan
npm run deploy:dry-run— wrangler picks upbuild.command, esbuild builds in 200ms, dry-run succeeds with the bundle uploaded as a static asset.git status --ignored— bundle is correctly gitignored.npm run typecheckis unaffected —tsconfig.jsonincludeis["src/**/*.ts"], deliberately skippingsrc-frontend/.pending_reviewand the "View my submissions →" link works./classifieds/, confirm the resume banner appears and finalizes successfully.Commit-by-commit
chore(deps)— npm packages.build—scripts/build-frontend.mjs+ gitignore.build— wranglerbuild.commandhook.feat(classifieds-wallet)— TypeScript source rewrite.fix(classifieds-ui)— HTML loads bundle, drops inline CDN code.