Babkenmes/widget cdn delivery#2045
Open
babkenmes wants to merge 29 commits into
Open
Conversation
Lets integrators receive widget updates without redeploying. The host installs only @layerswap/widget-react; the widget and its heavy deps (framer-motion, radix, formik, swr, wallet adapters) load at runtime from a content-hashed Rspack-built remote and stay out of the host's bundle. - packages/widget-react: @module-federation/runtime-based React loader. Exposes <LayerswapEmbed> with typed config, callbacks, walletDefaults, and host wagmi adoption via the EVM provider's externalWagmiConfig. Types re-exported from @layerswap/widget (optional peer-dep). - apps/widget-cdn: Rspack + @module-federation/enhanced remote. Exposes ./Widget = LayerswapProvider+Swap with getDefaultProviders(). Shares react/react-dom/wagmi/viem/@tanstack/react-query/zustand as singletons pinned to catalog versions. Includes Node built-in polyfills (buffer, crypto, stream, etc.) the wallet adapters reach for that Next.js auto-polyfilled. - examples/cdn-embed: Vite host. Shows the integration end-to-end with a WagmiProvider + connect button so host wallet state can be observed flowing into the widget via attachWagmiSync. - packages/widget: export CallbacksContextType so widget-react can type the callbacks prop. No behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
widget-cdn: static deploy with CORS + cache headers — short cache on remoteEntry.js / mf-manifest.json so updates roll out, immutable far-future cache on content-hashed chunks. cdn-embed: static Vite deploy. VITE_LAYERSWAP_REMOTE_ENTRY env var must be set on the Vercel project to point at the deployed widget-cdn URL. Both follow the existing monorepo pattern: build packages first via the root `pnpm build:packages`, then the app/example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Radix Popover.Portal defaults to document.body. The widget's Tailwind utilities are scoped to `.layerswap-styles` (postcss-prefixwrap), so a popover rendered as a child of <body> picks up none of them — no z-index, no background, no border-radius — and ends up hidden behind the wallet dialog overlay. The trigger looks dead. Portal into `#widget` (read lazily on open, like vaulModal does for the drawer itself) so the popover inherits the prefix scope and its utilities apply. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Component name now matches the @layerswap/widget-react package name. LayerswapEmbed was a leftover from the old @layerswap/embed package name and read weirdly inside @layerswap/widget-react. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The cdn-embed folder name was off-theme after the package rename (@layerswap/embed → @layerswap/widget-react). The new name describes what the example actually is: a host app integrating the @layerswap/widget-react package. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wagmi v2 auto-registers an EIP-6963-discovered connector for every announced provider alongside any bare connector you declare. With both `injected()` and an auto-discovered MetaMask connector active, `reconnect()` restores both against the same wallet on refresh — the widget then shows the same physical wallet twice in its connected list. Disable auto-discovery for this single-connector example; document the behavior inline so integrators understand the tradeoff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the #widget portal target into the shadcn PopoverContent wrapper so every popover in the widget is in-scope of `.layerswap-styles` by default. Callers that need a different target still pass `container` explicitly. Revert the local fix in ProviderPicker now that the default handles it. 13 other popovers across the widget (RouteSortingMenu, Slippage, AddressPicker, WalletsDropdown, etc.) were latently broken the same way and now work without per-site changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Several wallet adapter transitive deps requested older viem majors (2.23, 2.31). pnpm hoisted all three alongside the catalog 2.44.1, which caused MF's strict shared-singleton check to fire a warning per hoisted copy at every build, and bundled dead viem code into chunks the runtime never used. Override resolves everything to 2.44.1. Build is now warning-free. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@imtbl/sdk (~3 MB raw / ~1 MB Brotli) was statically imported at the root of @layerswap/wallets via the re-exports of createImmutablePassportProvider + imtblPassportLoginCallback. Every consumer of @layerswap/wallets — including the CDN remote — pulled the SDK into its module graph even when no Passport config was supplied. Move those value re-exports to a dedicated subpath: @layerswap/wallets/eager/imtbl-passport The main entry now exports only the lazy descriptor; the SDK enters the graph only via the descriptor's `await import(...)` thunk, which fires on connect-modal open and only when immutablePassport config is set. Update apps/bridge/pages/imtblRedirect.tsx (the OAuth callback that genuinely needs the eager path) to import from the new subpath. widget-cdn sync graph drops from ~3 MB initial + 3 MB lazy to ~1.76 MB sync only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The .iphone-x rules in globals.css (~100 lines) had no consumer anywhere in the codebase. They referenced /cnbs1.png as a host-served asset, which forced any CDN integration to either copy the file into their own public dir or accept a 404. Removing the dead rules makes the widget CSS self-contained and drops the asset requirement. Also drop the workaround copy from the example's public dir; the asset is no longer referenced. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Production build now emits to dist/v1/ with content-hashed chunks (`[name].[contenthash:8].js`). The entry point `remoteEntry.js` keeps a stable filename so loaders can find it; chunks are immutable so they can be cached far-future and served with the right cache rules. vercel.json updated to match: - /v1/remoteEntry.js + /v1/mf-manifest.json — short cache (60s), must-revalidate, so updates roll out quickly - /v1/*.<hash>.js — far-future immutable - /v1/* unhashed assets — 5min cache as a safe middle ground LAYERSWAP_CHANNEL env var lets CI produce pinned immutable builds under a version dir (e.g. v1.3.0/) alongside the rolling v1/. Dev mode unchanged (flat dist/, no hashing) — the dev-server proxies the artifact from the root and the example default URL still points there. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LayerswapWidget now accepts a `manifest` URL alongside the legacy
`remoteEntry`. When given:
1. Fetches `<channel>/manifest.json` (short-cache).
2. Honors `killSwitch: true` — refuses to load, fires onError with
a typed ManifestError.
3. With `verify: true`, verifies a detached ECDSA P-256 signature
over the canonical manifest body using a public key baked into
the package. Rejects if missing or invalid.
4. Resolves `manifest.remoteEntry` relative to the manifest URL,
then loads as before via @module-federation/runtime.
apps/widget-cdn/scripts/build-manifest.mjs emits manifest.json after
each production build. If LAYERSWAP_PRIVATE_KEY_PEM is set, the
manifest is signed with that key; otherwise an unsigned manifest is
emitted (the loader accepts it only when `verify: false`).
The bundled public key is a LOCAL PLACEHOLDER generated for testing.
Before pointing real integrators at the public CDN, generate the
production keypair in KMS/HSM, swap the public key constant in
`packages/widget-react/src/manifest.ts`, and bump the package
version — the public key is the trust anchor and rotating it
requires a widget-react release.
Tested end-to-end with a static CORS server serving dist/v1:
- Happy path: widget mounts.
- killSwitch: true → ManifestError('kill-switch'), onError fires,
widget does not mount.
- Tampered signature → ManifestError('signature'), onError fires,
widget does not mount.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Integrators can now drop chains from the default provider list via the
embed without installing wallet packages. Pass e.g.
<LayerswapWidget
walletProvidersConfig={{ exclude: ['tron', 'fuel'] }}
…
/>
and those providers are filtered out before LayerswapProvider renders.
The connect modal's chain filter popover updates accordingly — verified
by the Bitcoin/EVM/Solana/Starknet-only set with tron + fuel excluded.
Per-chain config overrides (e.g. swap WC project ID just for SVM,
inject a customConnection for EVM) are the natural next addition under
the same prop without breaking the integrator API.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real README: install, host wagmi adoption, multiInjectedProviderDiscovery gotcha, full props table, manifest/kill-switch/signature flow, failure modes, local-dev flow, security model. CSP block recommends script-src + connect-src allowlists covering the CDN origin, LayerSwap API, and WalletConnect relays. Documents why style-src needs 'unsafe-inline' today (style-loader runtime injection) and that removing it would be a build-time change in widget-cdn. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Production deploys of apps/widget-cdn now go through
.github/workflows/widget-cdn-deploy.yml on push to main (or
workflow_dispatch with a channel input). The workflow:
1. Stages LAYERSWAP_PRIVATE_KEY_PEM from secrets to a tempfile.
2. Runs `pnpm build`, which builds the remote and signs
`manifest.json` via the existing build-manifest script.
3. Runs `pnpm verify-manifest` (new) which round-trips the signature
against the public key constant bundled into @layerswap/widget-react.
Fails the deploy on key rotation mismatches before anything ships.
4. `vercel build` packages the already-built artifact, `vercel deploy
--prebuilt` uploads without re-running the build — Vercel never
touches the signing key.
5. Wipes the tempfile.
6. Smoke-tests the deployed manifest URL: reachable, CORS set, signed,
killSwitch off.
vercel.json disables git-integrated auto-deploys
(`git.deploymentEnabled.main: false`) so this workflow is the only path
to a signed production artifact.
`permissions: id-token: write` is set on the job for the eventual OIDC
upgrade to KMS-held keys — the README documents the migration shape.
Required GitHub secrets:
LAYERSWAP_PRIVATE_KEY_PEM - ECDSA P-256 private key whose public
half is baked into @layerswap/widget-react
VERCEL_TOKEN - deploy-rights API token
VERCEL_ORG_ID
VERCEL_PROJECT_ID
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the last security gap: signing the manifest only proved the
manifest was authentic. An attacker who controlled the CDN could keep
our signed manifest unchanged and replace any downstream chunk it
referenced — the loader would happily execute the tampered bytes.
This commit extends the (signed) manifest with a SHA-384 hash for
every JS file in the build:
{
"version": "0.1.0",
"remoteEntry": "./remoteEntry.js",
"chunks": {
"remoteEntry.js": "sha384-…",
"__federation_expose_Widget.6ba26f6e.js": "sha384-…",
"1499.09849eef.js": "sha384-…",
…253 entries…
},
"killSwitch": false,
"signature": "…"
}
At runtime, after the signature on the manifest is verified, the loader
installs an interceptor on `HTMLScriptElement.prototype.src` /
`setAttribute('src', …)`. Any time MF (or anything else) sets a script's
src to a URL under our CDN origin, the interceptor:
- Finds the filename in the manifest's chunks map
- Sets `integrity="sha384-…"` and `crossorigin="anonymous"` on the
element before the browser starts fetching
- For known-origin URLs not in the manifest, sets a deliberately
invalid integrity so the browser refuses to execute
The browser does the verification natively — fast (~5-15 ms per chunk
on modern hardware, hidden behind network), reliable, and using the
same SRI machinery `<script integrity>` already provides.
build-manifest.mjs walks dist/<channel> and computes SHA-384 over every
.js file before signing. verify-manifest.mjs (CI sanity check) rehashes
each file on disk and confirms it matches what the manifest claims —
catches "the manifest was signed with stale hashes" bugs before deploy.
Tested end-to-end with a tampered chunk on disk:
WIDGET_MOUNTS: 0
console.error: Failed to find a valid digest in the 'integrity'
attribute for resource '…__federation_expose_Widget.6ba26f6e.js'
with computed SHA-384 integrity '…'. The resource has been blocked.
ChunkLoadError surfaces to onError, fallback UI shows.
Restored the file: WIDGET_MOUNTS: 1, no SRI blocks, no errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ontext Security & correctness: - EVM: validate active account against connector's tracked accounts list - Starknet: validate node_url is a well-formed https/wss URL before RpcProvider - Starknet: move hydration re-entrancy guard to top of attemptHydration - Paradex: validate persisted paradexAddress format; drop tampered entries - TON: wrap Address.parse in try/catch, return undefined on malformed input - Replace silent `// TODO` + console.log catches with console.error across EVM, Bitcoin, SVM, TRON, TON, Fuel, Starknet services Architecture & React: - Fuel: use scoped fuelStore for connectedWallets instead of global useWalletStore - resolverContext: move setProviders side effect out of useMemo into useEffect; memoize provider arrays; back isInitialized with real post-commit state - createConnectionStoreFromHook: reset store to EMPTY_PROVIDER on unmount/destroy - LayerswapProvider: stabilize loadById/loadAll via ref to stop context cascade - WidgetWrapper: hoist walletConnectConfigs to module scope - Memoize createXXXProvider calls in playground + nextjs examples Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CRITICAL: canonicalize wasn't actually covering the chunks map.
`JSON.stringify(body, Object.keys(body).sort())` uses the array form
of the replacer, which is a PROPERTY ALLOWLIST applied recursively to
every nested object — not just the top level. The allowlist contained
the top-level field names (chunks, killSwitch, remoteEntry, signature,
version). When stringify recursed into `body.chunks`, it filtered
those keys against the same list — every chunk filename in
`chunks: { "1499.abc.js": "sha384-…", … }` got stripped because the
filenames aren't field names. Result: the signed bytes contained
`"chunks": {}`. Per-chunk hashes were not signed.
An attacker controlling the CDN could swap a chunk, update its entry
in the manifest's chunks map to the new hash, leave `signature`
untouched (it still verified because the canonical form excluded all
chunk entries), and the loader would set integrity to the attacker's
hash and run the malicious bytes. This defeated the entire CDN-tamper
protection that the previous SRI commit claimed to deliver.
Fix: hand-written recursive deterministic JSON serializer (sort keys
at every level, normalize undefined to null). Identical implementation
in three sites that all must agree byte-for-byte:
- apps/widget-cdn/scripts/build-manifest.mjs (signer)
- packages/widget-react/src/manifest.ts (loader verifier)
- apps/widget-cdn/scripts/verify-manifest.mjs (CI round-trip)
Old signatures don't verify under the new code — that's correct.
Re-sign after deploy.
Also addressed from the same review:
- vercel.json: added `/v1/manifest.json` CORS rule (was missing — the
smoke step `grep access-control-allow-origin` would have failed on
a real deploy).
- vercel.json: parameterized all source patterns over a channel
segment matching /v[0-9][0-9.]*/, so workflow_dispatch with
channel=v1.3.0 (or any future major) gets the same CORS + cache
headers automatically. Previously every chunk under a non-v1
channel would have shipped without CORS, and the SRI interceptor's
crossorigin="anonymous" would then make the browser block every
chunk → widget completely broken on any non-default channel.
- sri.ts: replaced the malformed `sha384-INVALID-UNMANIFEST-CHUNK`
sentinel with a syntactically valid SHA-384 SRI value
(`sha384-${'A'.repeat(64)}`). Malformed values may be discarded as
unparseable, leaving an empty metadata set and silently allowing
the resource. Also added `crossorigin="anonymous"` in the same
branch so CORS is consistent.
- sri.ts: dropped the dead HTMLElement.prototype `src` fallback.
- sri.ts: added a comment in lookupHash flagging the basename-only
match assumption.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…der discovery and remove injected connector
Babkenmes/widget cdn sri
Make `manifest` the only public way to load the widget. Drops the `remoteEntry` prop / `VITE_LAYERSWAP_REMOTE_ENTRY` escape hatch that bypassed the manifest layer (and thus kill-switch + signing). To keep local dev working, the widget-cdn rspack dev server now emits an unsigned `manifest.json` next to `remoteEntry.js`, so dev uses the same manifest path as prod instead of the legacy direct path. - LayerswapWidget: `manifest` now required; drop `remoteEntry` from the Loadable union, resolveSource, destructure, and useMemo deps. - widget-cdn rspack.config: dev-only plugin emits manifest.json. - example host: default manifest to the local dev server; verify driven by VITE_LAYERSWAP_VERIFY (off by default since the dev manifest is unsigned). - Docs updated to the manifest-only flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `walletProvidersConfig.include` alongside the existing `exclude`. `include` keeps only the listed provider ids; `exclude` still drops them. When both are set, `include` is applied first then `exclude` subtracts. Filtering happens before any `loadProvider()` call, so chains left out never dynamic-import their SDK — an `include` list gets the same lazy-loading win as `exclude`. EVM wagmi-config adoption now only replaces EVM when it survives the filters, instead of force-injecting it, so the lists are respected literally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- manifest: rename PLACEHOLDER_PUBLIC_KEY_SPKI_B64 → MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 (it is the live trust anchor, not a throwaway) and gate fetch caching on verify - workflow: pass VERCEL_TOKEN via env instead of inline --token CLI arg - evm: reject empty connector accounts instead of bypassing address validation - runtime: SSR-guard initRemote/loadWidget; pin shared React to ^18 || ^19 - widget-react: narrow sideEffects to sri.ts so the SRI patch isn't tree-shaken; relax node engines to >=18 - vercel: add HSTS, X-Content-Type-Options, X-Frame-Options headers - resolverContext: consolidate 6 provider memos into one stable object - popover: useLayoutEffect for container portal to avoid mount flash - LayerswapWidget: move onReady de-dup guard to a parent ref so it fires once Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces two packages and refactors widget-react to a thin wrapper, so the
CDN widget can be embedded from any environment with no host framework and the
loaders carry no dependency on the heavy @layerswap/widget package.
@layerswap/widget-js — framework-agnostic loader
- mountWidget(target, props, { manifest, verify }) → { update, destroy };
works from plain <script>, Vue, Angular, Svelte, etc. The remote owns its
own React root via a new './mount' CDN expose, so the host needs no React.
- Owns the security-critical loader primitives (manifest fetch/verify,
canonicalization, runtime SRI interceptor), extracted out of widget-react so
there is a single source of truth — no risk of the signing/verification
paths drifting between copies.
- runtime.initRemote takes an optional shared map: vanilla hosts let the remote
bundle its own React; React hosts share theirs.
@layerswap/widget-types — zero-runtime contract
- Single source of truth for the widget's public type surface (theme, config,
callbacks, props). Theme types move here; @layerswap/widget re-exports them.
- The widget's LayerswapWidgetConfig now refines the contract
(WidgetConfig & { settings: LayerSwapSettings; ... }) so the public and
internal types can't structurally diverge — drift fails to compile.
- Deep fields (settings, initialValues, callback payloads) stay open so the
package has no @layerswap/widget coupling.
widget-react
- Now depends only on widget-js (and re-exports its types); dropped the
@layerswap/widget peer + devDep entirely. Shares the host's React (^18||^19)
with the remote via the new shared map.
CDN comments reframe widget-js as the shared loader core (source of truth for
the key + canonicalization), not "the loader", since multiple loaders consume
the signed manifest. verify-manifest.mjs reads the public key from widget-js.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace Vercel static hosting with an R2 object store fronted by a Cloudflare Worker so widget builds are versioned, pinnable, and rollback-able without a rebuild. - Each build publishes to an immutable `dist/<version>/` prefix in R2, never overwritten. A single mutable `channels.json` maps rolling major channels (`/v1/`) to the current version; the Worker 302-redirects. - Loader resolves `remoteEntry` against the post-redirect URL so the remote + chunks anchor at the immutable version path. - Manifest now carries a real version (from @layerswap/widget) plus channel/gitSha/builtAt provenance. - New Worker (worker/) serves R2 with immutable caching + CORS; new deploy-r2/rollback-r2 scripts; CI workflow rewritten Vercel -> R2. - Rotate manifest signing keypair (public half baked into widget-js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinned version conflicted with package.json's packageManager field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ader Integrators no longer need to pass the CDN manifest URL. `manifest` is now optional, defaulting to the canonical Layerswap CDN (DEFAULT_MANIFEST_URL, rolling v1 channel), and `verify` defaults to true since that endpoint is signed. Override either to pin a build or target a local dev server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.