Skip to content

fix(classifieds): use npm + bundler instead of CDN, fix v7/v8 wallet API mismatch#693

Open
biwasxyz wants to merge 5 commits into
mainfrom
fix/classifieds-wallet-bundler
Open

fix(classifieds): use npm + bundler instead of CDN, fix v7/v8 wallet API mismatch#693
biwasxyz wants to merge 5 commits into
mainfrom
fix/classifieds-wallet-bundler

Conversation

@biwasxyz
Copy link
Copy Markdown
Contributor

Why this PR

After #682 merged, the new "Post a Classified" modal failed in two ways:

  1. "Wallet connection cancelled" — fired even when the user never saw a wallet prompt.
  2. "Couldn't load wallet support" — intermittent, especially on cold caches.

Both came from the same root cause: I had pinned @stacks/connect@7.10.1 on esm.sh but written the source against @stacks/connect@8. The v8 API (connect(), request()) does not exist in v7, so calling libs.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.json adds @stacks/connect@^8, @stacks/transactions@^7, @stacks/blockchain-api-client@^8 as runtime deps and esbuild as a devDep.
  • scripts/build-frontend.mjs (new, ~30 lines) walks src-frontend/*.ts entrypoints with esbuild and emits self-contained ESM bundles into public/.
  • wrangler.jsonc gains a build.command. CI runs npx wrangler deploy directly, and that path picks up the hook — no CI changes needed.
  • package.json also adds predev / predeploy hooks for the local npm run … flow.
  • public/classifieds/wallet-flow.bundle.js is gitignored.

Source rewrite

  • src-frontend/classifieds-wallet.ts (new) — the entire wallet flow as a normal TypeScript module with normal import statements. Uses:

    • @stacks/connect@8connect(), request("stx_transferSip10Ft", …), getLocalStorage(), disconnect(), isConnected(). The Promise API the source has always been written against.
    • Proper post-conditions via Pc:
      Pc.principal(stxAddress)
        .willSendEq(CLASSIFIED_PRICE_SATS)
        .ft(SBTC_CONTRACT_FULL, SBTC_ASSET_NAME)
      valid SIP-010 fungible post-condition that the wallet prompts the user to approve. Caps the transfer at exactly 3,000 sats; the network rejects anything else.
    • Dedicated stx_transferSip10Ft instead of a hand-rolled stx_callContract. Wallet UX is clearer — Leather/Xverse render a "send sBTC" prompt instead of "call contract".
    • MutationObserver to bind once #post-listing-btn appears in the DOM, so we don't race the inline grid-rendering code.
    • window.__aibtcClassifieds debug helpers (disconnect, isConnected, clearPending) for stuck-connection triage from the browser console.
  • public/classifieds/index.html drops 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

$ rm -f public/classifieds/wallet-flow.bundle.js
$ npm run deploy:dry-run
…
[custom build] Running: node scripts/build-frontend.mjs
[custom build]   public/classifieds/wallet-flow.bundle.js  1.4mb ⚠️
[custom build] ⚡ Done in 200ms
[custom build] built public/classifieds/wallet-flow.bundle.js
Total Upload: 1316.94 KiB / gzip: 251.57 KiB
--dry-run: exiting now.

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 up build.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 typecheck is unaffected — tsconfig.json include is ["src/**/*.ts"], deliberately skipping src-frontend/.
  • Manual smoke test against staging: open the modal, connect Leather, sign + broadcast, watch the live status update, verify the row appears in pending_review and the "View my submissions →" link works.
  • Manual smoke test of the resume path: start a payment, close the tab after the wallet confirms, reopen /classifieds/, confirm the resume banner appears and finalizes successfully.

Commit-by-commit

  1. chore(deps) — npm packages.
  2. buildscripts/build-frontend.mjs + gitignore.
  3. build — wrangler build.command hook.
  4. feat(classifieds-wallet) — TypeScript source rewrite.
  5. fix(classifieds-ui) — HTML loads bundle, drops inline CDN code.

biwasxyz and others added 5 commits April 30, 2026 20:52
… + 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>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
agent-news 017b6e7 Apr 30 2026, 03:09 PM

@github-actions
Copy link
Copy Markdown
Contributor

Preview deployed: https://agent-news-staging.hosting-962.workers.dev

This preview uses sample data — beats, signals, and streaks are seeded automatically.

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 submitToServer retry with graded delays handles indexing lag well.
  • MutationObserver for waiting on #post-listing-btn is 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.
  • validateForm returns a string | null error — clean functional approach, no exceptions for validation.
  • The setStatus / setError / setDone trio is a clear state-machine output layer. Easy to follow.
  • The window.__aibtcClassifieds debug export with a TypeScript declare global ambient 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants