npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@revley/upsell-sdk

v0.1.0

Published

Browser SDK for custom-coded post-purchase upsell pages

Readme

@revley/upsell-sdk

Browser SDK for custom-coded post-purchase upsell pages (REV-1759).

A merchant's developer writes plain HTML/CSS/JS, one file per node (1.html, 2.html, …). We host those files and render them in a sandboxed iframe inside our hosted journey. This SDK is what their code calls to read offer data and to accept or decline.

<script src="https://cdn.example.com/upsell.js" data-revley-flow-id="flow_123"></script>

Two layers

Reading data works anywhere, including on the developer's machine outside our iframe. Acting on the offer works only inside it: the page asks the parent to charge and progress, the parent does it, and the parent swaps the iframe's src to the next node.

That split is forced, not chosen:

  • The shopper session is an httpOnly SameSite=lax cookie scoped to /api/checkout/upsell. It is never returned in a response body, so there is no bearer token an iframe on another origin could hold, and the cookie is not sent cross-site.
  • sandbox="allow-scripts" (without allow-same-origin, which would let the frame remove its own sandbox) gives the page an opaque origin: no storage, no cookies, and Origin: null on every request.

So the parent holds the session and pushes data in over postMessage. Because the frame's origin is "null", the parent cannot validate event.origin — it must match event.source against the iframe's contentWindow.

Surface

| | | |---|---| | ready() | Resolves when context has arrived. Call first. | | env() | { inFrame, mode } — branch on being previewed. | | context() | Everything the page can read. See src/types.ts. | | tokens() | The 13 copy tokens, verbatim from the builder. | | fill(text) | {token} substitution. | | format(minor) | Money in the offer's currency. | | accept() | Charge and progress. | | decline() | Progress down the decline path. | | selectTier(i) | Re-quote for a quantity tier. | | submitSurvey(n) | 1–5, order-complete page only. | | on('change'\|'expire', cb) | Context replaced / window gone. | | autoHeight(), setHeight(px) | The parent cannot measure a cross-origin frame. |

Every read throws until ready() resolves, and every action rejects. The countdown in context().timer.secondsRemaining is recomputed on each call, so a page can render it from a loop of its own without the host pushing ticks.

accept() and decline() resolve with { outcome, message? }. On "advancing" and "completed" the host replaces this frame, so the promise may never settle — show progress rather than hanging logic off the resolution. "failed" means the charge did not go through and the shopper was not charged, but the journey continues anyway; surface the message instead of treating it as fatal.

Bind the accept control to canAccept rather than recomputing it. blockedBy says why: "busy" and "gate" come from the host, "expired" is worked out locally from the deadline. Decline is never blocked.

Developing a page outside the iframe

Reads work with no host. Point the script tag at a flow and the SDK fetches a preview payload:

<script src="/upsell.js"
        data-revley-flow-id="flow_123"
        data-revley-node="1"
        data-revley-preview-origin="https://checkout.example.com"></script>

To work with no network at all, set a context before the script loads:

<script>window.REVLEY_PREVIEW_CONTEXT = { /* see src/types.ts */ };</script>

env() reports { inFrame: false, mode: "preview" } there. accept() and decline() charge nothing and log what they would have done, resolving "advancing" so a page's success path still runs.

Parity, deliberately

This mirrors what the block page builder has and nothing more. In the builder, the platform supplies the money and the product identity; the merchant supplies everything else. Two things are therefore absent on purpose:

  • Product images. There is no catalog lookup at runtime. The builder's hero image is an authored URL, seeded once from the catalog when the merchant picks the product and never touched again. Custom pages ship their own art.
  • Subscription trial and renewal terms. rebillMode, rebillVal, nRen and trialVal drive billing but are never shown to a shopper today. The only subscription disclosure on a page is the cadence suffix ($29.00/mo).

Also not exposed, because the renderer ignores them: quote.discountMinor, quote.taxMinor (always 0), price.showWas, picker.accept, picker.per, tracker.bandBg, receipt.orderNumber, view.receiptUrl.

Open items before this can ship

  1. A public preview endpoint. Every existing data route needs the shopper cookie or a CRM JWT. Local development has neither. Same payload shape, no new data.
  2. Three settings need a home off page.blocks. With no blocks, these stop working, and the first two fail silently:
    • video.gate / gateSec → hasValidVideoGate() no longer locks accept
    • picker.defaultTier → the picker loses its default
    • video.gateSec → the window collapses to its 15-minute floor
  3. Node-to-file mapping. Positional numbering means inserting a step renumbers every file after it and silently repoints pages that are already written. An explicit file field per node makes a remap a visible diff.
  4. Accessibility. The current renderer meets a tested contract that customer-authored HTML will not by default: 44px minimum tap targets, luminance-based contrast on the accept button, aria-disabled over disabled, and prefers-reduced-motion overrides.

Development

pnpm install
pnpm typecheck
pnpm test
pnpm build     # dist/iife/upsell.global.js is the product

The host side is not in this package. Mapping RuntimeView to Context, answering the handshake, calling the runtime endpoints and swapping the iframe's src all belong to the hosted journey, which already holds the session cookie. Context in src/types.ts is the contract between the two.

The host half

Whatever frames a custom offer — the CRM's preview, then the shopper's journey — imports the trusted half from a separate entry point:

import { buildContext, createHost } from "@revley/upsell-sdk/host";

const host = createHost(iframeElement, {
  getContext: () => buildContext(view, { selectedTier, busy }),
  onAccept: () => acceptStep(view.step.id),
  onDecline: () => declineStep(view.step.id),
  onSelectTier: async (index) => buildContext(await currentUpsell(index)),
});

buildContext lives here rather than in the CRM or the checkout client because both need it, and two hand-written copies would drift the first time the quote shape changes.

It is a separate entry so @revley/upsell-core never reaches the bundle a shopper downloads: the IIFE stays at 7.3 KB and contains none of it.

Two things about createHost worth knowing before you write another one. A sandboxed frame's messages arrive with origin: "null", so the origin cannot identify anyone — the sender is matched on event.source against the frame's contentWindow instead. And it answers every greeting, not just the first, because the page repeats until answered and that is what stops the retry loop.