Skip to content

Fix theme dev serving theme asset for /cdn/extensions/ requests#7674

Open
wes-shaw wants to merge 1 commit into
Shopify:mainfrom
wes-shaw:river-fix-theme-dev-extension-asset-collision
Open

Fix theme dev serving theme asset for /cdn/extensions/ requests#7674
wes-shaw wants to merge 1 commit into
Shopify:mainfrom
wes-shaw:river-fix-theme-dev-extension-asset-collision

Conversation

@wes-shaw
Copy link
Copy Markdown

Summary

shopify theme dev was serving a theme asset in response to an app extension's asset request (/cdn/extensions/<uuid>/<app>/assets/<name>) whenever the theme had a same-named file. The colliding script then loaded twice (once as the theme asset, once as the extension asset that was silently swapped), causing duplicated execution — e.g. double-bound event handlers. Reported in this community thread (Klaviyo Email Marketing's app.js vs a theme app.js).

The live storefront serves both URLs correctly; this was local-dev only. Reproduces on main and on @shopify/cli 4.1.0.

Root cause

In packages/theme/src/cli/utilities/theme-environment/local-assets.ts, findLocalFile tried the theme matcher first via ??:

tryGetFile(/^(?:\/cdn\/.*?)?\/assets\/([^?]+)/, ctx.localThemeFileSystem) ??
tryGetFile(/^(?:\/ext\/cdn\/extensions\/.*?)?\/assets\/([^?]+)/, ctx.localThemeExtensionFileSystem) ??

The theme matcher's optional (?:\/cdn\/.*?)? prefix is greedy enough to swallow the entire /cdn/extensions/<uuid>/<app> segment, capturing only the basename after /assets/. tryGetFile derives the lookup key from the capture alone (joinPath('assets', matchedFileName)), discarding the URL path, so /cdn/extensions/.../assets/app.js resolves to assets/app.js in localThemeFileSystem and is served.

The extension matcher only accepts /ext/cdn/extensions/ (note /ext/), so it never gets a chance for a bare /cdn/extensions/ request, and the ?? short-circuits before getProxyHandler (which already forwards extension CDN traffic to cdn.shopify.com) is ever reached.

Fix

Tighten the theme regex with a negative lookahead so its /cdn/ prefix cannot be followed by extensions/, and broaden the extension regex to also accept /cdn/extensions/... in addition to /ext/cdn/extensions/....

const THEME_ASSET_PATTERN = /^(?:\/cdn\/(?!extensions\/).*?)?\/assets\/([^?]+)/
const THEME_EXTENSION_ASSET_PATTERN = /^(?:\/(?:ext\/)?cdn\/extensions\/.*?)?\/assets\/([^?]+)/

When the requested extension asset is not in localThemeExtensionFileSystem, findLocalFile returns {fileKey: undefined} and getAssetsHandler returns early — the request falls through to getProxyHandler which forwards to cdn.shopify.com and the real installed-app asset is returned.

Behaviour after fix

  • /cdn/extensions/<uuid>/<app>/assets/<name> → proxied to CDN, or served from localThemeExtensionFileSystem when present (covers theme-app-extension dev against the vanity CDN form).
  • /cdn/shop/t/<id>/assets/<name> → still served from localThemeFileSystem.
  • /ext/cdn/extensions/... → still served from localThemeExtensionFileSystem.
  • Bare /assets/<name> → unchanged.

Test coverage

New packages/theme/src/cli/utilities/theme-environment/local-assets.test.ts covers six cases for findLocalFile, including the regression (a theme assets/app.js must not be returned for a /cdn/extensions/.../assets/app.js request) and the locally-developed-extension precedence case. Confirmed locally: with the regex change reverted, exactly the 2 collision tests fail; with the fix applied, all 6 new tests pass and the full packages/theme test suite (163 tests) still passes, along with nx type-check theme and nx lint theme.

Tophatting

In a project that uses shopify theme dev:

  1. Install an app whose extension bundles an asset, e.g. assets/app.js.
  2. Add a theme asset with the same filename: assets/app.js, with distinct contents (e.g. console.log('THEME') vs the app's content).
  3. Run shopify theme dev and load a page that renders both:
    • theme tag: <script src="/cdn/shop/t/<id>/assets/app.js?v=...">
    • extension tag (from content_for_header): <script src="/cdn/extensions/<uuid>/<app>/assets/app.js">
  4. curl http://localhost:9292/cdn/extensions/<uuid>/<app>/assets/app.js — before the fix this returns the theme's app.js; after the fix it returns the real app's app.js (proxied from cdn.shopify.com).
  5. Confirm curl http://localhost:9292/cdn/shop/t/<id>/assets/app.js still returns the local theme's app.js.

Notes

Requested by Wes Shaw wes.shaw@shopify.com

When `shopify theme dev` was serving a local theme alongside an installed
app whose extension bundles a same-named asset (e.g. both ship
`assets/app.js`), a request for the extension's asset URL
(`/cdn/extensions/<uuid>/<app>/assets/app.js`) returned the **theme** asset
instead of being proxied to cdn.shopify.com.

The bug was in `findLocalFile` (`local-assets.ts`). Its theme matcher
ran first via `??` and its optional `(?:/cdn/.*?)?` prefix greedily
swallowed the `/cdn/extensions/<uuid>/<app>` segment, capturing only
`app.js`. `tryGetFile` then derived the lookup key from the capture
alone, found `assets/app.js` in `localThemeFileSystem`, and served it.
The extension matcher only accepted an `/ext/cdn/extensions/` prefix
and never got a chance for a bare `/cdn/extensions/` request, so the
`??` short-circuited before `getProxyHandler` could see the request.

The colliding script then loaded twice (once as the theme asset, once
as the extension asset that was silently swapped for the theme asset),
producing duplicated execution — e.g. double-bound event handlers.
The live storefront serves both URLs correctly; this was local-dev only.

Fix: tighten the theme regex with a negative lookahead so its
`/cdn/` prefix cannot be followed by `extensions/`, and broaden the
extension regex to also accept `/cdn/extensions/...` in addition to
`/ext/cdn/extensions/...`. When the requested extension asset is not
in `localThemeExtensionFileSystem`, the handler falls through and
`getProxyHandler` forwards the request to cdn.shopify.com, which
returns the real installed-app asset.

Behaviour:
- `/cdn/extensions/<uuid>/<app>/assets/<name>` → proxied to CDN
  (or served from `localThemeExtensionFileSystem` if locally
  developed and present there).
- `/cdn/shop/t/<id>/assets/<name>` → still served from
  `localThemeFileSystem`.
- `/ext/cdn/extensions/...` → still served from
  `localThemeExtensionFileSystem`.

Reported: https://community.shopify.dev/t/cli-is-serving-incorrect-cdn-assets/34726

Requested by Wes Shaw <wes.shaw@shopify.com>

Co-authored-by: Wes Shaw <wes.shaw@shopify.com>
@wes-shaw wes-shaw marked this pull request as ready for review May 29, 2026 22:16
Copilot AI review requested due to automatic review settings May 29, 2026 22:16
@wes-shaw wes-shaw requested review from a team as code owners May 29, 2026 22:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a bug where shopify theme dev would serve a local theme asset in response to an app extension's /cdn/extensions/<uuid>/<app>/assets/<name> request when the theme contained a same-named file. The overly-permissive theme-asset regex was capturing extension CDN paths and returning the wrong file.

Changes:

  • Tighten THEME_ASSET_PATTERN with a negative lookahead so /cdn/ cannot be followed by extensions/.
  • Broaden THEME_EXTENSION_ASSET_PATTERN to also match storefront-emitted /cdn/extensions/... URLs (in addition to /ext/cdn/extensions/...).
  • Export findLocalFile and add new unit tests covering bare, vanity-CDN, extension, and collision cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/theme/src/cli/utilities/theme-environment/local-assets.ts Replace inline regex literals with named, documented patterns; export findLocalFile.
packages/theme/src/cli/utilities/theme-environment/local-assets.test.ts New tests for findLocalFile, including the regression case.
.changeset/river-theme-dev-cdn-extensions-collision.md Patch-level changeset describing the fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants