diff --git a/.github/workflows/web-publish.yml b/.github/workflows/web-publish.yml index 5de03ea44..aa73adea8 100644 --- a/.github/workflows/web-publish.yml +++ b/.github/workflows/web-publish.yml @@ -1,4 +1,4 @@ -name: Web — Publish to npm +name: Web — Publish to npm and CDN on: release: @@ -10,7 +10,7 @@ on: required: false type: string dry-run: - description: "Run the full pipeline + pack but skip the actual publish. Defaults to true for manual safety; uncheck to actually publish." + description: "Run the full pipeline + pack but skip npm and CDN publishing. Defaults to true for manual safety; uncheck to publish." required: false type: boolean default: true @@ -25,7 +25,7 @@ concurrency: jobs: publish: - name: Publish @shopify/checkout-kit to npm + name: Publish @shopify/checkout-kit to npm and CDN # Only run when either: # - A GitHub Release tagged `web/X.Y.Z` is published (auto trigger), OR # - The workflow is manually dispatched from the `main` branch. The @@ -75,17 +75,20 @@ jobs: fi echo "✓ Tag '$TAG_NAME' matches package.json version '$VERSION_FROM_PKG'." - - name: Verify version is not already published + - name: Check whether version is already published + id: npm-version run: | set -euo pipefail NAME=$(node -p "require('./package.json').name") VERSION=$(node -p "require('./package.json').version") URL="https://registry.npmjs.org/${NAME}/${VERSION}" if curl -fs "$URL" > /dev/null; then - echo "::error::${NAME}@${VERSION} is already published on npm. Bump platforms/web/package.json before re-running." - exit 1 + echo "already_published=true" >> "$GITHUB_OUTPUT" + echo "::notice::${NAME}@${VERSION} is already published — skipping npm publish." + else + echo "already_published=false" >> "$GITHUB_OUTPUT" + echo "::notice::${NAME}@${VERSION} is not yet on npm — safe to proceed." fi - echo "::notice::${NAME}@${VERSION} is not yet on npm — safe to proceed." - name: Lint (typecheck + oxlint + format) run: pnpm lint @@ -93,15 +96,18 @@ jobs: - name: Test run: pnpm test - - name: Build - run: pnpm build + - name: Build npm package + run: pnpm build:npm + + - name: Build CDN assets + run: pnpm build:cdn - - name: Verify package (publint) + - name: Verify npm package and CDN assets run: pnpm verify - name: Pack and inspect contents run: | - pnpm pack --pack-destination /tmp/web-publish + pnpm pack --config.ignore-scripts=true --pack-destination /tmp/web-publish echo "Tarball contents:" tar -tzf /tmp/web-publish/*.tgz | sort @@ -129,22 +135,167 @@ jobs: fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Select CDN channel + id: cdn-policy + run: node scripts/cdn-release-policy.mjs + env: + DIST_TAG: ${{ steps.tag.outputs.tag }} + PRERELEASE: ${{ github.event.release.prerelease }} + + # The deploy identity and bucket are configured as `npm-web` environment + # secrets rather than committed, so they are masked in logs and only + # available to jobs that declare the environment. Fail early and clearly + # if any are missing; never print their values. + - name: Check CDN deployment configuration + env: + HAS_PROJECT: ${{ secrets.CDN_GCP_PROJECT_ID != '' }} + HAS_PROVIDER: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER != '' }} + HAS_SERVICE_ACCOUNT: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT != '' }} + HAS_BUCKET: ${{ secrets.CDN_BUCKET != '' }} + run: | + set -euo pipefail + MISSING="" + [ "$HAS_PROJECT" = "true" ] || MISSING="$MISSING CDN_GCP_PROJECT_ID" + [ "$HAS_PROVIDER" = "true" ] || MISSING="$MISSING CDN_GCP_WORKLOAD_IDENTITY_PROVIDER" + [ "$HAS_SERVICE_ACCOUNT" = "true" ] || MISSING="$MISSING CDN_GCP_SERVICE_ACCOUNT" + [ "$HAS_BUCKET" = "true" ] || MISSING="$MISSING CDN_BUCKET" + if [ -n "$MISSING" ]; then + echo "::error::Missing npm-web environment secrets:${MISSING}. See platforms/web/RELEASING.md." + exit 1 + fi + + # Pre-flight: prove the CDN identity works before publishing to npm so a + # broken binding fails the run before anything irreversible happens. + # Runs on dry runs too. Deliberately does NOT write a credentials file or + # export env vars — only a short-lived token held in a step output — so + # no package code that runs later can pick up CDN credentials. + - name: Pre-flight CDN identity + id: gcp-preflight + uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 + with: + project_id: ${{ secrets.CDN_GCP_PROJECT_ID }} + workload_identity_provider: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT }} + token_format: access_token + create_credentials_file: false + export_environment_variables: false + + - name: Verify CDN bucket access + env: + GCP_ACCESS_TOKEN: ${{ steps.gcp-preflight.outputs.access_token }} + CDN_BUCKET: ${{ secrets.CDN_BUCKET }} + CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} + run: | + set -euo pipefail + # Ask GCS which of the permissions the upload steps need the identity + # actually holds. Side-effect free, so safe on dry runs. Overwriting + # an existing loader requires delete as well as create. + REQUIRED="storage.objects.create storage.objects.delete storage.objects.list" + QUERY="" + for PERM in $REQUIRED; do QUERY="${QUERY}permissions=${PERM}&"; done + STATUS=$(curl -sS -o /tmp/iam-test.json -w '%{http_code}' \ + -H "Authorization: Bearer ${GCP_ACCESS_TOKEN}" \ + "https://storage.googleapis.com/storage/v1/b/${CDN_BUCKET}/iam/testPermissions?${QUERY%&}") + if [ "$STATUS" != "200" ]; then + echo "::error::Cannot query IAM on the CDN deployment bucket (HTTP ${STATUS}). Check the workload identity binding and environment secrets." + cat /tmp/iam-test.json + exit 1 + fi + GRANTED=$(node -p "(JSON.parse(require('fs').readFileSync('/tmp/iam-test.json','utf8')).permissions ?? []).join(' ')") + MISSING="" + for PERM in $REQUIRED; do + case " $GRANTED " in *" $PERM "*) ;; *) MISSING="$MISSING $PERM" ;; esac + done + if [ -n "$MISSING" ]; then + echo "::error::CDN deploy identity is missing permissions on the deployment bucket:${MISSING}" + exit 1 + fi + echo "::notice::CDN deploy identity holds ${REQUIRED} on the deployment bucket (deploying to ${CDN_PREFIX}/)" + # `DIST_TAG` is passed via `env:` (not direct ${{ }} interpolation) so # that a workflow_dispatch input like `latest$(whoami)` is treated as a # literal string by bash rather than command substitution. + # `--ignore-scripts` skips `prepack`, so the tarball contains the dist/ + # that was built and verified above rather than a fresh, unverified build. - name: Publish to npm - if: ${{ !inputs.dry-run }} - run: pnpm dlx npm@11.5.1 publish --no-git-checks --tag "$DIST_TAG" --access public --provenance + if: ${{ !inputs.dry-run && steps.npm-version.outputs.already_published != 'true' }} + run: pnpm dlx npm@11.5.1 publish --no-git-checks --ignore-scripts --tag "$DIST_TAG" --access public --provenance env: DIST_TAG: ${{ steps.tag.outputs.tag }} NPM_CONFIG_PROVENANCE: "true" NPM_TOKEN: "" NODE_AUTH_TOKEN: "" + # Redeploying an already-published version to the CDN is only safe when + # the checkout matches what was published: a release tag, or a re-run + # (run_attempt > 1, same SHA) of the manual run that published it, e.g. + # to repair a failed CDN upload. A *fresh* manual dispatch runs from + # `main`, which may have moved on without a version bump; deploying that + # would put code on the CDN that was never published to npm, labelled + # with the old version. + - name: Guard against deploying unpublished code + if: ${{ github.event_name == 'workflow_dispatch' && github.run_attempt == '1' && steps.npm-version.outputs.already_published == 'true' }} + env: + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + MESSAGE="${VERSION} is already on npm. A fresh manual run cannot redeploy it to the CDN because main may not match the published tarball. Re-run the workflow run that published it (release or manual) instead, or bump the version." + if [ "${DRY_RUN}" = "true" ]; then + echo "::warning::${MESSAGE}" + else + echo "::error::${MESSAGE}" + exit 1 + fi + + - name: Prepare CDN release + id: cdn + if: ${{ !inputs.dry-run }} + env: + CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} + run: | + set -euo pipefail + RELEASE_DIR="/tmp/checkout-kit-cdn/${CDN_PREFIX}" + mkdir -p "$RELEASE_DIR/assets" "$RELEASE_DIR/loader" + cp dist-cdn/web-components.js dist-cdn/web-components.js.map "$RELEASE_DIR/loader" + cp -R dist-cdn/assets/. "$RELEASE_DIR/assets" + + # Full authentication (credentials file) only now, after all package + # code has run, and only for the upload steps that need it. + - name: Authenticate to Google Cloud + if: ${{ steps.cdn.outcome == 'success' }} + uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 + with: + project_id: ${{ secrets.CDN_GCP_PROJECT_ID }} + workload_identity_provider: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT }} + + # Upload content-addressed chunks before the loader references them. + # Cache-Control is not set per object: the CDN applies one caching policy + # to every /checkout-kit/ path. See platforms/web/CDN-PUBLISHING.md. + - name: Upload CDN implementation chunks + if: ${{ steps.cdn.outcome == 'success' }} + uses: google-github-actions/upload-cloud-storage@386ab77f37fdf51c0e38b3d229fad286861cc0d0 # v2.2.1 + with: + path: /tmp/checkout-kit-cdn/${{ steps.cdn-policy.outputs.prefix }}/assets + destination: ${{ secrets.CDN_BUCKET }}/${{ steps.cdn-policy.outputs.prefix }}/assets + parent: false + + - name: Upload CDN loader + if: ${{ steps.cdn.outcome == 'success' }} + uses: google-github-actions/upload-cloud-storage@386ab77f37fdf51c0e38b3d229fad286861cc0d0 # v2.2.1 + with: + path: /tmp/checkout-kit-cdn/${{ steps.cdn-policy.outputs.prefix }}/loader + destination: ${{ secrets.CDN_BUCKET }}/${{ steps.cdn-policy.outputs.prefix }} + parent: false + - name: Dry-run summary if: ${{ inputs.dry-run }} env: DIST_TAG: ${{ steps.tag.outputs.tag }} + CDN_CHANNEL: ${{ steps.cdn-policy.outputs.channel }} + CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} run: | - echo "::notice::Dry-run requested — skipped npm publish." - echo "Would have published with: --tag $DIST_TAG --access public --provenance" + echo "::notice::Dry-run requested — skipped npm and CDN publishing." + echo "Would have published to npm with: --tag $DIST_TAG --access public --provenance" + echo "Would have uploaded the ${CDN_CHANNEL} CDN loader to: /checkout-kit/${CDN_PREFIX}/web-components.js" diff --git a/dev.yml b/dev.yml index 3a9ede7c9..c4010abb6 100644 --- a/dev.yml +++ b/dev.yml @@ -1023,8 +1023,15 @@ commands: desc: "Web Checkout Kit commands" subcommands: build: - desc: Build the @shopify/checkout-kit package + desc: Build the @shopify/checkout-kit npm package and CDN assets run: cd platforms/web && pnpm build + subcommands: + npm: + desc: Build the npm package into dist/ + run: cd platforms/web && pnpm build:npm + cdn: + desc: Build the CDN loader and component chunks into dist-cdn/ + run: cd platforms/web && pnpm build:cdn test: desc: Run unit tests with coverage @@ -1059,7 +1066,7 @@ commands: /opt/dev/bin/dev web sample build clean: - desc: Remove dist and coverage directories + desc: Remove npm and CDN build outputs and coverage run: cd platforms/web && pnpm clean start: @@ -1079,7 +1086,7 @@ commands: run: cd platforms/web && pnpm sample:preview verify: - desc: Run Web package verification + desc: Verify the Web npm package and CDN assets run: cd platforms/web && pnpm verify e2e: diff --git a/platforms/web/.gitignore b/platforms/web/.gitignore index 427cbdac7..a52a9558e 100644 --- a/platforms/web/.gitignore +++ b/platforms/web/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ +dist-cdn/ sample/dist/ build/ coverage/ diff --git a/platforms/web/CDN-PUBLISHING.md b/platforms/web/CDN-PUBLISHING.md new file mode 100644 index 000000000..0fb2ed633 --- /dev/null +++ b/platforms/web/CDN-PUBLISHING.md @@ -0,0 +1,244 @@ +# Checkout Kit CDN publishing + +Checkout Kit Web is published to npm today. CDN publishing adds a browser-native +ES module distribution for consumers that cannot, or prefer not to, use a +package manager. + +## Public URL contract + +Checkout Kit has a dedicated CDN namespace. URLs are evergreen within a +Checkout Kit major version: + +```text +https://cdn.shopify.com/checkout-kit/v4/web-components.js +``` + +Compatible minor and patch releases replace the assets served from that major +path. A breaking Checkout Kit API or browser-support change requires a new +major URL, such as `/checkout-kit/v5/web-components.js`. + +The CDN major is the Checkout Kit library major; it is not a protocol version. + +### Internal testing channel + +> **Not a public entrypoint.** The unstable URL exists so Checkout Kit +> maintainers can exercise prereleases through the real CDN before promotion. +> It is not documented for consumers, is not covered by any compatibility or +> availability guarantee, and may break, change, or disappear at any time. Do +> not reference it in consumer-facing documentation, samples, support replies, +> or production code. + +Each major has a moving, maintainer-only loader under `v/unstable/`. It +may change incompatibly on any deployment. Retest reported issues against its +current build; historical builds are not preserved at exact-version URLs. The +default npm prerelease tag remains `next`; the CDN name `unstable` describes the +compatibility contract. A new deployment reaches the channel within the CDN's +cache window (see [Caching](#caching-and-asset-retention)), typically under 30 +minutes; check the loader's `version` export to confirm which build a page +received. + +The directory form keeps the loader's relative imports within +`v4/unstable/assets/`, separate from stable `v4/assets/`. A filename such as +`v4/checkout-kit-unstable.js` would resolve the existing `./assets/` imports into +the stable assets directory, requiring build changes to provide the same +isolation. Both channels use the same build and loader API. + +## Component versioning + +### Current model + +The initial loader-managed components share the Checkout Kit major. This mirrors +the current Web package: its root entrypoint and any subpath entrypoints are +published from one package version and one release stream. + +This gives consumers one simple compatibility model: a component loaded from a +`v4` loader is part of Checkout Kit major 4. Adding a new compatible component +or updating an existing compatible component does not change the loader URL. + +### Limitation + +A breaking public change to one loader-managed component requires a new Checkout +Kit major URL for every component, even when the other components have no +breaking change. This is intentionally conservative, but it can create +unnecessary coordinated upgrades as the set of components grows. + +## Module loader + +`web-components.js` is a stable ES module loader rather than a bundle containing +every Checkout Kit capability. The name mirrors Storefront Web Components +(`/storefront/web-components.js`), but unlike that bundle, importing it alone +registers nothing: consumers import it and load only the component they need. +Per-component drop-in files, if added later, belong under +`v/web-components/.js`. + +```js +import {loadComponents} from "https://cdn.shopify.com/checkout-kit/v4/web-components.js"; + +await loadComponents(["shopify-checkout"]); +``` + +The initial supported component is `shopify-checkout`; component names are the +custom element tags they register. `loadComponents` takes an array so a page +can request several components in one call. Nothing is loaded implicitly: an +empty list is rejected, every name is validated before anything is fetched, +and the call rejects if any component fails to load. The +loader resolves each component +to a self-contained implementation chunk under the same major-version directory +and registers ``. Repeating a request for the same component +is safe, and concurrent requests share one load. + +Browsers remember a failed dynamic import for that URL for the rest of the +page, so a single transient network error would otherwise make the component +unloadable. The loader therefore retries a failed import (up to three attempts +with backoff) through a cache-busting query string on the chunk URL, which the +browser treats as a fresh module. The chunk URL is injected into the loader at +build time; registration is idempotent, so a retry after a partial failure +cannot double-register. + +The loader also exports `version`, the Checkout Kit package version it was built +from, and `supportedComponents`. + +Additional components may be added to the loader in compatible releases. The +wallets work will be considered as a future component after its public +entrypoint and API are settled; it is not part of the initial CDN contract. + +Implementation chunks are content-hashed deployment details, not public URLs. +Consumers should import a documented loader URL and use its API. + +## Browser artifacts + +The Web package owns two separate builds from the same source and version: + +The npm component subpath (`@shopify/checkout-kit/shopify-checkout`) and CDN +loader (`src/cdn-loader.ts`) both use +`src/components/shopify-checkout/register.ts`, which registers the element +through `ShopifyCheckout.register()`. The npm root import registers nothing. +New components get their own npm subpaths and loader entries. +Component implementation and registration live together, with no separate CDN +component source tree. + +- `pnpm build:npm` uses `vite.config.ts` to produce the npm package in `dist/`. +- `pnpm build:cdn` uses `vite.cdn.config.ts` to produce the loader and hashed + implementation chunks in `dist-cdn/`, which is excluded from the npm package. +- `pnpm build` runs both; `pnpm verify` checks both distributions, including + loading them together on the same page. + +Each build cleans only its own output directory. The CDN build does not depend +on npm output or a published npm release. `pnpm pack` builds only the npm +distribution; the release workflow builds and verifies both before publishing. + +The initial CDN release contains one ESM artifact family, built to the browser +floor declared in the package's `browserslist` (the same floor the npm package +documents and lints against). We are deliberately not publishing separate +modern and baseline builds yet. Introducing a second artifact family or raising +the supported browser floor is a compatibility decision that requires its own +review; a breaking change requires a new CDN major URL. + +## Publishing + +The Web release workflow builds, tests, and validates both distributions before it +selects one CDN destination: + +| Release | CDN loader | +| --- | --- | +| Stable package version, not marked as a GitHub prerelease, npm tag `latest` | `v/web-components.js` | +| SemVer prerelease or marked as a GitHub prerelease, with any npm tag | `v/unstable/web-components.js` | +| Stable package version on any other npm tag, including `next`, `beta`, or `experimental` | `v/unstable/web-components.js` | + +A manual `latest` override cannot promote a prerelease to the stable CDN URL. +All preview npm channels share the same unstable CDN destination for that major; +each deployment replaces the previous preview. Stable releases do not update +the unstable URL. Dry runs report the selected channel and destination. + +For a non-dry-run release the workflow: + +1. derives the numeric major from `platforms/web/package.json`; +2. uploads the loader's content-hashed chunks and source maps from `dist-cdn/` + into the selected channel's `assets/` directory; +3. uploads that channel's `web-components.js` only after its implementation chunks + are available. + +Before publishing to npm, the workflow obtains a short-lived token for the +CDN deploy identity and asks GCS to confirm it holds the `create`, `delete`, +and `list` object permissions the upload steps need on the +CDN deployment bucket, so a broken or under-privileged identity +fails the run before anything irreversible happens. That pre-flight writes no credentials file and exports no environment +variables; full authentication happens only after npm publication, immediately +before the upload steps, so package code never runs with CDN credentials +available. npm publication uses `--ignore-scripts` so the tarball contains the +`dist/` that was built and verified earlier in the job. Dry runs exercise the +pre-flight without uploading. + +Re-running a release's workflow run for an already-published npm version +skips npm publication and redeploys that release's CDN assets to the channel +selected by the same rules; the checkout is the release tag, so the deployed +code matches the published tarball. Re-running an older release intentionally +replaces that channel's loader with the selected release, allowing a rollback. +A fresh manual `workflow_dispatch` builds `main`, so it fails if the version is +already published rather than deploying code to the CDN that never shipped to +npm. Re-running an existing workflow run (release or manual) is allowed, since +it rebuilds the same commit; use that to repair a run whose CDN upload failed +after npm publication. +The workflow does not require versions to increase on each deployment. +Rollbacks are subject to the same cache window as deployments (see below). + +### Limitations + +- Releases run from `main`, so the workflow does not currently support + patching an older major after a new major has shipped. Doing so would need a + maintenance branch and a CDN channel override, because the stable gate + requires the npm `latest` tag and npm has only one `latest` across majors. + This is a known gap; revisit before the first `5.0.0`. +- The stable URL for a major returns 404 until that major's first stable + release has been deployed. + +### Caching and asset retention + +The CDN applies one caching policy to every path under `/checkout-kit/`, +regardless of per-object metadata. It is the same policy used for Storefront +Web Components: + +```text +CDN edge: public, max-age=1800 +Browser: public, max-age=600, must-revalidate +``` + +The workflow therefore does not set `Cache-Control` on uploaded objects, and +there is no cache purge after upload. Consequences: + +- A new deployment, or a rollback, is visible to all consumers within about + 30 minutes (up to 30 minutes at the edge, then up to 10 minutes in browsers + that already hold a copy). This applies equally to the stable and unstable + loaders. +- Content-hashed chunks are also cached for at most 10 minutes in the browser. + Correctness does not depend on long-lived chunk caching: a loader only ever + references chunks that were uploaded before it, and chunks are never deleted, + so a cached loader and a fresh loader both resolve to valid chunks. +- Testers on the unstable channel should confirm the build they received using + the loader's `version` export rather than assuming the latest deployment. + +Uploads retain older chunks so cached loaders and open pages can finish lazy +loading. This workflow does not configure automatic deletion. A separate cleanup +policy can be scoped to `v/unstable/assets/`: it must retain every chunk +reachable from the current loader and allow a grace period for previous loaders. +Do not apply an age-only deletion rule to the entire unstable directory, because +it could delete the current build when no new preview has shipped recently. + +## npm compatibility + +Only the npm component entry (`@shopify/checkout-kit/shopify-checkout`) registers +``; the root import (`@shopify/checkout-kit`) exports the class +without registering it. The component entry is marked side-effectful so bundlers +retain registration. Package verification bundles each import combination as a +consumer and checks which elements are registered. It also loads the +independently built npm and CDN distributions in both orders: whichever +registers `` first keeps it, and the npm root alongside the CDN +loader leaves registration to the loader. + +## Non-goals + +- An unversioned Checkout Kit CDN URL. +- Exact-version CDN URLs such as `/v4.0.0/`. +- Public URLs for individual implementation chunks. +- Separate modern and baseline artifact families. +- Loading every future Checkout Kit capability by default. diff --git a/platforms/web/README.md b/platforms/web/README.md index d9befb4fe..6bc9246d9 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -25,6 +25,7 @@ Check out our blog to - [Platform Requirements](#platform-requirements) - [Getting Started](#getting-started) - [Installation](#installation) + - [CDN](#cdn) - [Basic Usage](#basic-usage) - [Programmatic Usage](#programmatic-usage) - [Usage with other frameworks](#usage-with-other-frameworks) @@ -84,6 +85,24 @@ npm install @shopify/checkout-kit@next Once the first stable `4.0.0` ships, the standard `pnpm add @shopify/checkout-kit` (no version specifier) will work and pull from the `latest` dist-tag. +### CDN + +If you'd rather not use a package manager, load Checkout Kit from Shopify's CDN +as an ES module and register the component you need: + +```html + +``` + +The URL is evergreen within a major version: compatible updates are delivered +automatically, and a breaking change ships under a new major path. The loader +also exports `version`, the Checkout Kit release it was built from. + +The CDN entrypoint becomes available with the first stable `4.0.0` release. + ## Basic Usage Import the component once anywhere in your application. This entry registers diff --git a/platforms/web/RELEASING.md b/platforms/web/RELEASING.md index 9a2923cf2..7486ec2a7 100644 --- a/platforms/web/RELEASING.md +++ b/platforms/web/RELEASING.md @@ -58,9 +58,18 @@ Click through and approve. Once approved, the workflow: 1. Validates the tag's version matches `package.json` -2. Runs the full pipeline (lint, test, build, publint) +2. Runs lint and tests, builds npm and CDN separately, and verifies both outputs 3. Packs the tarball and prints its contents -4. Publishes to npm with the appropriate dist-tag and SLSA provenance +4. Verifies the CDN deploy identity holds the bucket permissions the upload + needs (no credentials are left on disk at this point) +5. Publishes the verified `dist/` to npm with the appropriate dist-tag and + SLSA provenance (`--ignore-scripts`, so `prepack` does not rebuild) +6. Deploys the matching major-version CDN loader and its private implementation + chunks from `dist-cdn/`: stable versions on `latest` that are not marked as + GitHub prereleases go to `v/web-components.js`; all other releases go + to the testing-only `v/unstable/web-components.js` + +See [CDN publishing](./CDN-PUBLISHING.md) for the CDN URL contract and loader behavior. After it's green, sanity check: @@ -75,6 +84,33 @@ npm view @shopify/checkout-kit version The package page on npm shows a _Provenance_ badge linking to the workflow run that built it. +The CDN caches `/checkout-kit/` paths for up to 30 minutes at the edge and 10 +minutes in the browser, with no purge on upload. Allow for that before checking +the deployed loader, and confirm the build with its `version` export: + +```js +// In a browser console (the loader is minified, so grep is not reliable): +(await import("https://cdn.shopify.com/checkout-kit/v4/unstable/web-components.js")).version; +(await import("https://cdn.shopify.com/checkout-kit/v4/web-components.js")).version; +``` + +### Bad deploy + +A bad stable deploy stays live for up to ~40 minutes (30 at the edge, then 10 +in browsers) unless purged. To roll back: + +1. Re-run the workflow run of the release you want to restore (Actions → the + release's run → "Re-run all jobs"). It skips the already-published npm + version and redeploys that release's CDN assets. A *fresh* manual dispatch + refuses to deploy an already-published version, because `main` may no + longer match the published tarball; re-running an existing run is fine. +2. Purge the loader URL (`/checkout-kit/v/web-components.js`) from the CDN + edge using the internal CDN purge process. Only the loader needs purging; + chunks are content-hashed. Browser caches cannot be purged and expire within + 10 minutes. +3. Fix forward with a new patch release; `npm deprecate` the bad version if it + also shipped to npm. + ## Tag and dist-tag conventions | Release type | Example tag | npm dist-tag | `npm install` resolves to | @@ -100,9 +136,9 @@ The dist-tag is computed from three layered signals, in priority order: If none of the above applies (stable version, no override, not flagged as pre-release), the workflow publishes under `latest`. -If you ever genuinely want to publish a `-alpha.X` version under `latest` -(rare — usually a mistake), use the `tag` `workflow_dispatch` input to -explicitly override. +The `tag` override controls npm publication. Even if a prerelease is explicitly +published under npm's `latest` tag, it still goes to the unstable CDN URL. +Non-`latest` tags all share that same unstable destination within the major. The `web/` tag prefix is required so the publish workflow knows the release is for the web platform. Other platforms have their own prefixes: @@ -120,7 +156,8 @@ You can trigger the workflow directly without creating a GitHub Release: 2. Choose the branch (usually `main`) 3. Optionally: - **Override dist-tag** — e.g. `latest`, `next`, `beta`, `experimental` - - **Dry run** — runs everything except the final `npm publish`. Use this + - **Dry run** — runs validation and reports the selected CDN URL without + publishing to npm or uploading CDN assets. Use this to sanity-check the pipeline before a real publish, or to verify a misconfigured release. @@ -160,6 +197,26 @@ In the repo's _Settings → Environments → New environment_: The required-reviewer rule means every publish requires explicit human approval, even if the workflow somehow ran without authorization. +#### CDN deployment secrets + +The CDN deploy identity and destination are **environment secrets** on +`npm-web`, not values in the workflow file. They are identifiers rather than +credentials (authentication is OIDC, minted per run), but keeping them out of +the public repository and masked in logs limits what a reader learns about the +deployment. The workflow fails early, without printing values, if any is +missing. + +| Secret | Contents | +| --- | --- | +| `CDN_GCP_PROJECT_ID` | Google Cloud project that owns the deployment bucket and identity | +| `CDN_GCP_WORKLOAD_IDENTITY_PROVIDER` | Full resource name of the GitHub Actions workload identity provider | +| `CDN_GCP_SERVICE_ACCOUNT` | Email of the deploy service account | +| `CDN_BUCKET` | Name of the CDN deployment bucket | + +The values live in the internal infrastructure configuration for Checkout Kit; +ask a maintainer rather than reconstructing them. Because they are secrets, +GitHub masks them in logs; the workflow additionally avoids echoing them. + ## Troubleshooting ### "Tag implies version X but package.json has Y" @@ -181,6 +238,13 @@ causes: Confirm the npm Trusted Publisher settings match the workflow's `environment: name:` and the workflow's filename exactly. +### "Missing npm-web environment secrets" + +The CDN deployment secrets above are not set on the `npm-web` environment, or +the job is not running with that environment. Add them in _Settings → +Environments → npm-web → Environment secrets_. Dry runs need them too, since +the pre-flight exercises the deploy identity. + ### Publish failed mid-way; some files showed up on npm npm doesn't allow republishing the same version, even if the previous @@ -208,11 +272,11 @@ LICENSE README.md package.json dist/ (built JS, .d.ts, custom-elements.json, source map) -src/ (TypeScript source for consumers who want to read it) ``` -Test files (`*.test.ts`), the playground (`sample/`), the `consumer-test` -harness, dev configs, and lockfiles are all excluded. +The separate CDN output (`dist-cdn/`), TypeScript source, test files, the +playground (`sample/`), dev configs, and lockfiles are all excluded. `prepack` +runs `pnpm build:npm` only; `pnpm build` builds both npm and CDN artifacts. You can preview exactly what will be published before tagging a release: diff --git a/platforms/web/dependency-cruiser.config.mjs b/platforms/web/dependency-cruiser.config.mjs index c96fc923d..0bcf25017 100644 --- a/platforms/web/dependency-cruiser.config.mjs +++ b/platforms/web/dependency-cruiser.config.mjs @@ -13,16 +13,16 @@ export default { { name: "all-runtime-source-is-reachable-from-public-entrypoint", comment: - "Production source must be reachable from a package entry (src/index.ts or a component's register.ts) so it is either shipped or deleted.", + "Production source must be reachable from a public entrypoint (the npm entry src/index.ts, a component's register.ts, or the CDN loader src/cdn-loader.ts) so it is either shipped or deleted.", severity: "error", - from: { path: "^src/(index|components/[^/]+/register)\\.ts$" }, + from: { path: "^src/(index|cdn-loader|components/[^/]+/register)\\.ts$" }, to: { reachable: false, pathNot: [ "(^|/)node_modules/", "^package\\.json$", // The entries themselves; a module doesn't count as reachable from itself. - "^src/(index|components/[^/]+/register)\\.ts$", + "^src/(index|cdn-loader|components/[^/]+/register)\\.ts$", // Type-only modules are erased at compile time, so nothing reaches them at runtime. "\\.types\\.ts$", "\\.d\\.ts$", diff --git a/platforms/web/package.json b/platforms/web/package.json index 519b80def..f6cb7ae21 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -55,9 +55,11 @@ "ecommerce" ], "scripts": { - "clean": "rm -rf dist sample/dist coverage", - "build": "vite build && pnpm run build:manifest", - "build:manifest": "cem analyze --globs 'src/**/*.ts' --exclude 'src/**/*.test.ts' --outdir dist", + "clean": "rm -rf dist dist-cdn sample/dist coverage", + "build": "pnpm run build:npm && pnpm run build:cdn", + "build:npm": "vite build && pnpm run build:manifest", + "build:cdn": "vite build --config vite.cdn.config.ts", + "build:manifest": "cem analyze --globs 'src/**/*.ts' --exclude 'src/**/*.test.ts' 'src/cdn-loader.ts' --outdir dist", "dev": "vite build --watch", "test": "vitest run --coverage", "test:watch": "vitest", @@ -73,17 +75,18 @@ "sample:build": "vite build --config sample/vite.config.ts", "sample:preview": "vite preview --config sample/vite.config.ts", "sample:typecheck": "tsc --noEmit -p sample/tsconfig.json", + "scripts:typecheck": "tsc --noEmit -p scripts/tsconfig.json", "verify": "publint && vitest run --config vitest.package.config.ts", "snapshot": "./scripts/create_snapshot", "compare-snapshot": "./scripts/compare_snapshot", - "prepack": "pnpm run build", - "scripts:typecheck": "tsc --noEmit -p scripts/tsconfig.json" + "prepack": "pnpm run build:npm" }, "devDependencies": { "@custom-elements-manifest/analyzer": "^0.10.4", "@shopify/checkout-kit-protocol": "workspace:*", "@shopify/checkout-kit-telemetry": "workspace:*", "@types/node": "^22.10.0", + "@types/semver": "^7.8.0", "@typescript-eslint/parser": "^8.70.1", "@vitest/coverage-v8": "^4.1.0", "browserslist-to-esbuild": "^2.1.1", @@ -94,6 +97,7 @@ "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "publint": "^0.3.12", + "semver": "^7.8.5", "typescript": "5.9.3", "vite": "^8.3.4", "vite-plugin-dts": "^4.5.4", diff --git a/platforms/web/pnpm-lock.yaml b/platforms/web/pnpm-lock.yaml index e7aa060eb..6af1cd7b6 100644 --- a/platforms/web/pnpm-lock.yaml +++ b/platforms/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.18 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 '@typescript-eslint/parser': specifier: ^8.70.1 version: 8.71.0(eslint@10.11.0)(typescript@5.9.3) @@ -50,6 +53,9 @@ importers: publint: specifier: ^0.3.12 version: 0.3.24 + semver: + specifier: ^7.8.5 + version: 7.8.5 typescript: specifier: 5.9.3 version: 5.9.3 @@ -1067,6 +1073,9 @@ packages: '@types/node@22.19.18': resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==, tarball: https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -2092,12 +2101,12 @@ packages: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==, tarball: https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz} semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.4.tgz} engines: {node: '>=10'} hasBin: true semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, tarball: https://registry.npmjs.org/semver/-/semver-7.8.5.tgz} engines: {node: '>=10'} hasBin: true @@ -3021,6 +3030,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.8.0': {} + '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -3189,7 +3200,7 @@ snapshots: '@web/config-loader@0.1.3': dependencies: - semver: 7.7.4 + semver: 7.8.5 '@xn-sakina/rml-darwin-arm64@2.8.0': optional: true diff --git a/platforms/web/scripts/cdn-release-policy.mjs b/platforms/web/scripts/cdn-release-policy.mjs new file mode 100644 index 000000000..b7321cfe2 --- /dev/null +++ b/platforms/web/scripts/cdn-release-policy.mjs @@ -0,0 +1,20 @@ +import { appendFileSync, readFileSync } from "node:fs"; + +import semver from "semver"; + +const { version } = JSON.parse(readFileSync("package.json", "utf8")); +const parsed = semver.parse(version); +if (parsed === null) { + throw new Error(`Package version '${version}' is not valid SemVer.`); +} +const isPrerelease = parsed.prerelease.length > 0; +// npm channel overrides must not promote prereleases to the evergreen CDN URL. +const stable = + !isPrerelease && process.env.PRERELEASE !== "true" && process.env.DIST_TAG === "latest"; +const channel = stable ? "stable" : "unstable"; +const prefix = stable ? `v${parsed.major}` : `v${parsed.major}/unstable`; + +appendFileSync(process.env.GITHUB_OUTPUT, `channel=${channel}\nprefix=${prefix}\n`); +process.stdout.write( + `::notice::Version '${version}' selects the ${channel} CDN URL: /checkout-kit/${prefix}/web-components.js\n`, +); diff --git a/platforms/web/scripts/cdn-release-policy.test.ts b/platforms/web/scripts/cdn-release-policy.test.ts new file mode 100644 index 000000000..9ece8c821 --- /dev/null +++ b/platforms/web/scripts/cdn-release-policy.test.ts @@ -0,0 +1,154 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +const script = fileURLToPath(new URL("./cdn-release-policy.mjs", import.meta.url)); + +interface RoutingCase { + name: string; + version: string; + tag: string; + prerelease: string; + channel: "stable" | "unstable"; + prefix: string; +} + +const routingCases: ReadonlyArray = [ + { + name: "stable release", + version: "4.1.0", + tag: "latest", + prerelease: "false", + channel: "stable", + prefix: "v4", + }, + { + name: "manual stable release", + version: "4.1.0", + tag: "latest", + prerelease: "", + channel: "stable", + prefix: "v4", + }, + { + name: "alpha", + version: "4.0.0-alpha.4", + tag: "next", + prerelease: "true", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "unflagged beta", + version: "4.1.0-beta.1", + tag: "next", + prerelease: "", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "beta with latest override", + version: "4.1.0-beta.1", + tag: "latest", + prerelease: "", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "GitHub prerelease with latest override", + version: "4.1.0", + tag: "latest", + prerelease: "true", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "stable version on next", + version: "4.1.0", + tag: "next", + prerelease: "", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "stable version on beta", + version: "4.1.0", + tag: "beta", + prerelease: "", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "stable version on experimental", + version: "4.1.0", + tag: "experimental", + prerelease: "", + channel: "unstable", + prefix: "v4/unstable", + }, + { + name: "stable build metadata", + version: "4.1.0+build-1", + tag: "latest", + prerelease: "", + channel: "stable", + prefix: "v4", + }, + { + name: "next major prerelease", + version: "5.0.0-rc.1", + tag: "next", + prerelease: "true", + channel: "unstable", + prefix: "v5/unstable", + }, + { + name: "next major stable release", + version: "5.0.0", + tag: "latest", + prerelease: "false", + channel: "stable", + prefix: "v5", + }, +]; + +describe("CDN release policy", () => { + const fixtures: string[] = []; + + afterEach(async () => { + await Promise.all(fixtures.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function runPolicy(version: string, tag: string, prerelease: string): Promise { + const fixture = await mkdtemp(join(tmpdir(), "checkout-kit-release-")); + fixtures.push(fixture); + await writeFile(join(fixture, "package.json"), JSON.stringify({ version })); + const output = join(fixture, "github-output"); + + execFileSync(process.execPath, [script], { + cwd: fixture, + env: { ...process.env, GITHUB_OUTPUT: output, DIST_TAG: tag, PRERELEASE: prerelease }, + stdio: "pipe", + }); + + return readFile(output, "utf8"); + } + + it.each(routingCases)( + "routes $name ($version, tag=$tag, prerelease=$prerelease) to $prefix", + async ({ version, tag, prerelease, channel, prefix }) => { + await expect(runPolicy(version, tag, prerelease)).resolves.toBe( + `channel=${channel}\nprefix=${prefix}\n`, + ); + }, + ); + + it("rejects versions without a numeric SemVer major", async () => { + await expect(runPolicy("invalid.0.0", "latest", "")).rejects.toThrow(/is not valid SemVer/); + }); +}); diff --git a/platforms/web/scripts/package.test.ts b/platforms/web/scripts/package.test.ts new file mode 100644 index 000000000..a01747cdd --- /dev/null +++ b/platforms/web/scripts/package.test.ts @@ -0,0 +1,143 @@ +// Verifies `dist/` and `dist-cdn/` via `pnpm verify` after `pnpm build`. +// See vitest.package.config.ts. +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const distDir = join(packageRoot, "dist"); +const cdnDistDir = join(packageRoot, "dist-cdn"); + +// Native ESM preserves relative imports and uses a fresh registry for each test. +function runWithBrowser(script: string): unknown { + const happyDomUrl = import.meta.resolve("happy-dom"); + const stdout = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { Window } from ${JSON.stringify(happyDomUrl)}; + const window = new Window(); + globalThis.window = window; + globalThis.document = window.document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.customElements = window.customElements; + try { + const result = await (async () => { ${script} })(); + console.log(JSON.stringify(result)); + } finally { + await window.happyDOM.close(); + } + `, + ], + { stdio: "pipe", encoding: "utf8" }, + ); + return JSON.parse(stdout.trim().split("\n").at(-1) ?? "null"); +} + +describe("built distributions", () => { + const fixtures: string[] = []; + + afterEach(async () => { + await Promise.all(fixtures.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function createFixture(prefix: string): Promise { + const fixture = await mkdtemp(join(tmpdir(), prefix)); + fixtures.push(fixture); + return fixture; + } + + it.each(["v4", "v4/unstable"])("CDN loader resolves its own chunks from %s", async (prefix) => { + const fixture = await createFixture("checkout-kit-cdn-"); + await writeFile(join(fixture, "package.json"), JSON.stringify({ type: "module" })); + const channelDir = join(fixture, prefix); + await mkdir(channelDir, { recursive: true }); + await cp(join(cdnDistDir, "web-components.js"), join(channelDir, "web-components.js")); + await cp(join(cdnDistDir, "assets"), join(channelDir, "assets"), { recursive: true }); + + // Only this channel's artifacts are present, so imports into the npm + // output or another CDN channel would fail. + const loaderUrl = pathToFileURL(join(channelDir, "web-components.js")).href; + const result = runWithBrowser(` + const { loadComponents, version } = await import(${JSON.stringify(loaderUrl)}); + const registeredBefore = customElements.get("shopify-checkout") !== undefined; + await loadComponents(["shopify-checkout"]); + await loadComponents(["shopify-checkout"]); + const open = typeof customElements.get("shopify-checkout")?.prototype.open; + return { version, registeredBefore, open }; + `); + expect(result).toEqual({ + version: expect.stringMatching(/^\d+\.\d+\.\d+/), + registeredBefore: false, + open: "function", + }); + }); + + async function distributionUrls() { + const fixture = await createFixture("checkout-kit-distributions-"); + await writeFile(join(fixture, "package.json"), JSON.stringify({ type: "module" })); + await cp(distDir, join(fixture, "npm"), { recursive: true }); + await cp(cdnDistDir, join(fixture, "cdn"), { recursive: true }); + return { + npm: (entry: string) => pathToFileURL(join(fixture, "npm", entry)).href, + cdn: pathToFileURL(join(fixture, "cdn/web-components.js")).href, + }; + } + + it.each(["npm", "cdn"])( + "keeps the first registration when the npm component entry and CDN loader both load (%s first)", + async (first) => { + const urls = await distributionUrls(); + const result = runWithBrowser(` + const loads = { + npm: () => import(${JSON.stringify(urls.npm("shopify-checkout.js"))}), + cdn: async () => { + const { loadComponents } = await import(${JSON.stringify(urls.cdn)}); + await loadComponents(["shopify-checkout"]); + }, + }; + await loads[${JSON.stringify(first)}](); + const registered = customElements.get("shopify-checkout"); + await loads[${JSON.stringify(first === "npm" ? "cdn" : "npm")}](); + return { + sameConstructor: registered !== undefined && registered === customElements.get("shopify-checkout"), + open: typeof document.createElement("shopify-checkout").open, + }; + `); + + expect(result).toEqual({ sameConstructor: true, open: "function" }); + }, + ); + + it.each(["npm", "cdn"])( + "leaves registration to the CDN loader when the npm root loads alongside it (%s first)", + async (first) => { + const urls = await distributionUrls(); + const result = runWithBrowser(` + const loads = { + npm: () => import(${JSON.stringify(urls.npm("index.js"))}), + cdn: async () => { + const { loadComponents } = await import(${JSON.stringify(urls.cdn)}); + await loadComponents(["shopify-checkout"]); + }, + }; + await loads[${JSON.stringify(first)}](); + const afterFirst = customElements.get("shopify-checkout") !== undefined; + await loads[${JSON.stringify(first === "npm" ? "cdn" : "npm")}](); + return { + afterFirst, + open: typeof document.createElement("shopify-checkout").open, + }; + `); + + // The root registers nothing, so only the CDN loader defines the element. + expect(result).toEqual({ afterFirst: first === "cdn", open: "function" }); + }, + ); +}); diff --git a/platforms/web/src/cdn-loader.retry.test.ts b/platforms/web/src/cdn-loader.retry.test.ts new file mode 100644 index 000000000..ef1a9cfca --- /dev/null +++ b/platforms/web/src/cdn-loader.retry.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// A failed dynamic import is remembered by browsers for that URL, so the +// loader retries through a cache-busting URL. From source there is no build +// time chunk URL, so retries re-import the static specifier; mocking that +// module lets us drive the attempt sequence. +describe("Checkout Kit CDN loader retries", () => { + afterEach(() => { + vi.doUnmock("./components/shopify-checkout/register"); + vi.resetModules(); + vi.useRealTimers(); + }); + + async function loaderWithFailures(failures: number) { + let attempts = 0; + vi.doMock("./components/shopify-checkout/register", () => { + attempts += 1; + if (attempts <= failures) { + throw new TypeError("Failed to fetch dynamically imported module"); + } + return {}; + }); + const loader = await import("./cdn-loader"); + return { loader, attempts: () => attempts }; + } + + it("retries a failed component import and resolves once it succeeds", async () => { + vi.useFakeTimers(); + const { loader, attempts } = await loaderWithFailures(2); + + const result = loader.loadComponents(["shopify-checkout"]); + await vi.runAllTimersAsync(); + + await expect(result).resolves.toBeUndefined(); + expect(attempts()).toBe(3); + }); + + it("gives up after the last attempt with the final error", async () => { + vi.useFakeTimers(); + const { loader, attempts } = await loaderWithFailures(Number.POSITIVE_INFINITY); + + const result = loader.loadComponents(["shopify-checkout"]); + result.catch(() => {}); + await vi.runAllTimersAsync(); + + // vitest wraps a throwing mock factory in its own error; the loader + // surfaces whatever the final import rejected with. + await expect(result).rejects.toThrow(/error when mocking a module/); + expect(attempts()).toBe(3); + }); + + it("can be called again after a sequence fails outright", async () => { + vi.useFakeTimers(); + const { loader, attempts } = await loaderWithFailures(4); + + const first = loader.loadComponents(["shopify-checkout"]); + first.catch(() => {}); + await vi.runAllTimersAsync(); + await expect(first).rejects.toThrow(/error when mocking a module/); + expect(attempts()).toBe(3); + + const second = loader.loadComponents(["shopify-checkout"]); + await vi.runAllTimersAsync(); + await expect(second).resolves.toBeUndefined(); + expect(attempts()).toBe(5); + }); + + it("does not reload a component that already loaded", async () => { + vi.useFakeTimers(); + const { loader, attempts } = await loaderWithFailures(0); + + await loader.loadComponents(["shopify-checkout"]); + await loader.loadComponents(["shopify-checkout"]); + + expect(attempts()).toBe(1); + }); + + it("validates every name before fetching anything", async () => { + const { loader, attempts } = await loaderWithFailures(0); + + await expect(loader.loadComponents(["shopify-checkout", "wallets"])).rejects.toThrow( + "Unsupported Checkout Kit component: wallets", + ); + expect(attempts()).toBe(0); + }); + + it("shares one in-flight load between concurrent callers", async () => { + vi.useFakeTimers(); + const { loader, attempts } = await loaderWithFailures(1); + + const first = loader.loadComponents(["shopify-checkout"]); + const second = loader.loadComponents(["shopify-checkout"]); + await vi.runAllTimersAsync(); + + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + expect(attempts()).toBe(2); + }); +}); diff --git a/platforms/web/src/cdn-loader.test.ts b/platforms/web/src/cdn-loader.test.ts new file mode 100644 index 000000000..c9e7ff471 --- /dev/null +++ b/platforms/web/src/cdn-loader.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { loadComponents, supportedComponents, version } from "./cdn-loader"; +import { CK_VERSION } from "./version"; + +describe("Checkout Kit CDN loader", () => { + it("exposes the package version it was built from", () => { + expect(version).toBe(CK_VERSION); + expect(version).toMatch(/^\d+\.\d+\.\d+/); + }); + + it("lists and loads the embedded checkout component", async () => { + expect(supportedComponents).toEqual(["shopify-checkout"]); + + await loadComponents(["shopify-checkout"]); + await loadComponents(["shopify-checkout"]); + + expect(customElements.get("shopify-checkout")).toBeDefined(); + }); + + it("resolves when the component is already registered", async () => { + expect(customElements.get("shopify-checkout")).toBeDefined(); + + await expect(loadComponents(["shopify-checkout"])).resolves.toBeUndefined(); + }); + + it("rejects an empty list rather than loading nothing or everything", async () => { + await expect(loadComponents([])).rejects.toThrow( + "loadComponents() requires at least one component name.", + ); + }); + + it("accepts repeated names in one call", async () => { + await expect(loadComponents(["shopify-checkout", "shopify-checkout"])).resolves.toBeUndefined(); + }); + + it("rejects the whole call when any name is unsupported", async () => { + await expect(loadComponents(["shopify-checkout", "wallets"])).rejects.toThrow( + "Unsupported Checkout Kit component: wallets", + ); + }); + + it.each(["wallets", "constructor", "__proto__", "toString", ""])( + "rejects unsupported component name %j", + async (name) => { + await expect(loadComponents([name])).rejects.toThrow( + `Unsupported Checkout Kit component: ${name}`, + ); + }, + ); +}); diff --git a/platforms/web/src/cdn-loader.ts b/platforms/web/src/cdn-loader.ts new file mode 100644 index 000000000..45d0f9610 --- /dev/null +++ b/platforms/web/src/cdn-loader.ts @@ -0,0 +1,133 @@ +// Inlined at build time. Not imported from ./version so the loader stays a +// single request with no shared chunk of its own. +declare const CHECKOUT_KIT_PACKAGE_VERSION: string; +// Injected by the CDN build (see vite.cdn.config.ts) with the hashed URL of the +// shopify-checkout chunk, relative to this module. Undefined when running +// from source (tests), in which case retries re-import the static specifier. +declare const CHECKOUT_KIT_SHOPIFY_CHECKOUT_CHUNK: string | undefined; + +/** + * The Checkout Kit version this loader was built from. + * + * CDN URLs are evergreen within a major and cached at the edge and in the + * browser, so use this to confirm which build a page actually received. + */ +export const version: string = CHECKOUT_KIT_PACKAGE_VERSION; + +export const supportedComponents = ["shopify-checkout"] as const; + +export type CheckoutKitComponent = (typeof supportedComponents)[number]; + +interface ComponentSource { + /** Static import the bundler can see and chunk. */ + load: () => Promise; + /** Build-time URL of the chunk, for cache-busting retries. */ + url: string | undefined; +} + +const componentSources: Record = { + "shopify-checkout": { + load: () => import("./components/shopify-checkout/register"), + url: + typeof CHECKOUT_KIT_SHOPIFY_CHECKOUT_CHUNK === "string" + ? CHECKOUT_KIT_SHOPIFY_CHECKOUT_CHUNK + : undefined, + }, +}; + +const MAX_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 100; + +const loaded = new Set(); +const pending = new Map>(); + +// Browsers remember a failed import per URL for the life of the page, so a +// retry URL must never be reused: each load sequence gets its own token, and +// each attempt within it a suffix. Otherwise a sequence that fails outright +// during an outage would leave every retry URL poisoned for later calls. +let retrySequence = 0; + +/** + * Loads Checkout Kit components and registers their custom elements. + * + * Nothing is loaded implicitly: an empty list is rejected, and every name is + * validated before anything is fetched, so a typo cannot leave the page + * half-loaded. Loading a component more than once is safe: the + * browser caches the module, custom-element registration happens once, and + * concurrent requests share one in-flight load. The returned promise rejects + * if any component fails; a later call retries only what has not loaded. + * + * A failed dynamic import is remembered by the browser for that URL, so a + * transient network error would otherwise make the component unloadable for + * the rest of the page. Retries therefore use a cache-busting query string on + * the chunk URL, which the browser treats as a fresh module. + */ +export async function loadComponents(components: readonly string[]): Promise { + // An empty list is almost certainly a mistake, and silently resolving would + // leave it unclear whether nothing or everything was loaded. Nothing is ever + // loaded implicitly; name every component you need. + if (components.length === 0) { + throw new Error("loadComponents() requires at least one component name."); + } + + // Validate everything first, then load each distinct component once. + const names = new Set(components.map(toComponentName)); + await Promise.all([...names].map(loadOne)); +} + +function toComponentName(component: string): CheckoutKitComponent { + // Own-property check so inherited names like "constructor" are rejected. + if (!Object.hasOwn(componentSources, component)) { + throw new Error(`Unsupported Checkout Kit component: ${component}`); + } + return component as CheckoutKitComponent; +} + +async function loadOne(name: CheckoutKitComponent): Promise { + if (loaded.has(name)) return; + + let inflight = pending.get(name); + if (inflight === undefined) { + inflight = loadAndRemember(name).finally(() => pending.delete(name)); + pending.set(name, inflight); + } + await inflight; +} + +async function loadAndRemember(name: CheckoutKitComponent): Promise { + await loadWithRetries(componentSources[name]); + loaded.add(name); +} + +async function loadWithRetries(source: ComponentSource): Promise { + let lastError: unknown; + retrySequence += 1; + const sequence = `${Date.now().toString(36)}-${retrySequence}`; + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + try { + // The first attempt of the first sequence uses the static import so the + // browser can share it with any preload; every later attempt needs a URL + // it has never seen fail. + if (source.url === undefined || (attempt === 0 && retrySequence === 1)) { + await source.load(); + } else { + const retryUrl = new URL(`${source.url}?retry=${sequence}.${attempt}`, import.meta.url) + .href; + await import(/* @vite-ignore */ retryUrl); + } + return; + } catch (error) { + lastError = error; + if (attempt < MAX_ATTEMPTS - 1) { + await delay(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + } + + throw lastError; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/platforms/web/src/components/shopify-checkout/register-existing.test.ts b/platforms/web/src/components/shopify-checkout/register-existing.test.ts new file mode 100644 index 000000000..3433b68a2 --- /dev/null +++ b/platforms/web/src/components/shopify-checkout/register-existing.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +// Separate file: the component entry must load before anything registers . +describe("shopify-checkout component entry with an existing registration", () => { + it("leaves an existing registration in place instead of throwing", async () => { + class Existing extends HTMLElement {} + customElements.define("shopify-checkout", Existing); + + await expect(import("./register")).resolves.toBeDefined(); + + expect(customElements.get("shopify-checkout")).toBe(Existing); + }); +}); diff --git a/platforms/web/vite.cdn.config.ts b/platforms/web/vite.cdn.config.ts new file mode 100644 index 000000000..b98d84ed3 --- /dev/null +++ b/platforms/web/vite.cdn.config.ts @@ -0,0 +1,62 @@ +import {fileURLToPath} from 'node:url'; + +import browserslistToEsbuild from 'browserslist-to-esbuild'; +import {defineConfig, type Plugin} from 'vite'; + +import packageJson from './package.json'; + +// Failed imports need a fresh URL on retry. Inject the component chunk's +// hashed filename once the bundler has resolved the chunk graph. +function injectCdnChunkUrls(): Plugin { + return { + name: 'checkout-kit:inject-cdn-chunk-urls', + renderChunk(code, chunk) { + if (chunk.name !== 'web-components') return null; + const target = chunk.dynamicImports.find((file) => /(^|\/)shopify-checkout-/.test(file)); + if (!target) { + this.error('CDN loader does not dynamically import the shopify-checkout chunk.'); + } + return { + code: code.replaceAll('CHECKOUT_KIT_SHOPIFY_CHECKOUT_CHUNK', JSON.stringify(`./${target}`)), + map: null, + }; + }, + }; +} + +export default defineConfig({ + define: { + CHECKOUT_KIT_PACKAGE_VERSION: JSON.stringify(packageJson.version), + }, + plugins: [injectCdnChunkUrls()], + build: { + target: browserslistToEsbuild(), + sourcemap: true, + minify: true, + emptyOutDir: true, + outDir: fileURLToPath(new URL('./dist-cdn', import.meta.url)), + lib: { + entry: { + 'web-components': fileURLToPath(new URL('./src/cdn-loader.ts', import.meta.url)), + }, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.js`, + }, + rollupOptions: { + external: [], + output: { + minify: { + compress: true, + mangle: true, + codegen: true, + }, + // Registration modules share a filename; name chunks after their component. + chunkFileNames: (chunk) => { + const component = chunk.facadeModuleId?.match(/\/components\/([^/]+)\/register\.ts$/)?.[1]; + return `assets/${component ?? '[name]'}-[hash].js`; + }, + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, +}); diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index ad26a175a..5c899710c 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ dts({ entryRoot: fromRoot('src'), include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts'], + exclude: ['src/**/*.test.ts', 'src/cdn-loader.ts'], outDir: fromRoot('dist'), tsconfigPath: fromRoot('tsconfig.json'), insertTypesEntry: true, @@ -83,7 +83,7 @@ export default defineConfig({ globals: true, setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.ts', 'sample/**/*.test.ts', 'scripts/**/*.test.ts'], - // Package tests run against the build via pnpm verify. + // Artifact tests need both builds; see vitest.package.config.ts. exclude: ['**/node_modules/**', 'scripts/*package.test.ts'], coverage: { provider: 'v8', diff --git a/platforms/web/vitest.package.config.ts b/platforms/web/vitest.package.config.ts index 34eeb50a5..eb3d5c7cc 100644 --- a/platforms/web/vitest.package.config.ts +++ b/platforms/web/vitest.package.config.ts @@ -1,6 +1,6 @@ import {defineConfig} from 'vitest/config'; -// Verifies dist/. Kept out of the default vitest config +// Verifies dist/ and dist-cdn/. Kept out of the default vitest config // so `pnpm test` does not depend on a prior build; `pnpm verify` runs it. export default defineConfig({ test: {