Skip to content

fix(storage): normalize slashes in storage basePath - #1249

Merged
gronxb merged 1 commit into
gronxb:mainfrom
giaBaoJS:fix/storage-key-base-path-slashes
Sep 6, 2026
Merged

fix(storage): normalize slashes in storage basePath#1249
gronxb merged 1 commit into
gronxb:mainfrom
giaBaoJS:fix/storage-key-base-path-slashes

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The bug

createStorageKeyBuilder joins the configured storage basePath with the rest of the key exactly as given:

return [basePath || "", ...args].filter(Boolean).join("/");

A basePath of /releases/ therefore yields /releases//bundles/<id>/bundle.zip instead of releases/bundles/<id>/bundle.zip.

s3Storage is unaffected because it strips the surrounding slashes itself before building its keys:

const normalizedBasePath = config.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
const getStorageKey = createStorageKeyBuilder(normalizedBasePath);

r2Storage (both the S3 and Wrangler profiles), supabaseStorage and firebaseStorage all pass config.basePath straight through, so only those three carry the empty segment. s3Storage.spec.ts already uses basePath: "/releases/" as a normal input, so this shape is expected to work.

Reproduce

Configure any of r2 / supabase / firebase storage with basePath: "/releases/" and deploy a bundle with shared assets.

The empty segment is not cosmetic, because the bundle storageUri is parsed back into a key elsewhere:

  1. upload writes the bundle to /releases//bundles/<id>/bundle.zip and returns r2://bucket//releases//bundles/<id>/bundle.zip.
  2. deploy derives the shared asset root with createStorageRootUriWithPath, which splits the pathname and calls .filter(Boolean). That drops the empty segment, giving r2://bucket/releases/assets.
  3. Each asset URI recorded in the manifest is therefore r2://bucket/releases/assets/sha256/<aa>/<hash>.png, whose key parses as releases/assets/....
  4. But the asset itself is uploaded through getStorageKey again, landing at /releases//assets/sha256/<aa>/<hash>.png.

The two keys never agree. exists misses on every asset, so deploy re-uploads all of them on every run, and the manifest points at keys that were never written.

Root cause

The shared key builder does not normalize basePath, so a leading or trailing slash becomes an empty path segment that the asset URI resolver later discards.

The fix

Strip the surrounding slashes in createStorageKeyBuilder, matching what s3Storage already does. s3Storage keeps its own normalization and is unchanged, since the operation is idempotent. Fixing the shared helper also covers custom plugins, which docs/content/docs/storage-plugins/custom-storage.mdx tells authors to build on createStorageKeyBuilder.

What the tests assert

plugins/plugin-core/src/createStorageKeyBuilder.spec.ts (new) asserts createStorageKeyBuilder("/releases/")("bundles/id", "bundle.zip") returns releases/bundles/id/bundle.zip, and that a basePath of "" or "/" contributes no segment.

plugins/supabase/src/supabaseStorage.spec.ts adds an end-to-end case asserting that with basePath: "/releases/" the plugin calls bucket.upload with releases/bundles/bundle.zip. Without the fix it is called with /releases//bundles/bundle.zip.

pnpm -w lint is clean and the existing storage suites (s3, r2, firebase, supabase, bundle/asset storage layout, storage prune) still pass.

`createStorageKeyBuilder` joined `basePath` with the rest of the key as
given, so a `basePath` of `/releases/` produced
`/releases//bundles/<id>/bundle.zip` instead of
`releases/bundles/<id>/bundle.zip`.

The empty segment is not cosmetic. `createStorageRootUriWithPath` drops
empty path segments when it derives `assetBaseStorageUri` from the bundle
`storageUri`, so deploy records shared asset URIs under
`releases/assets/...` while the same asset is uploaded to
`/releases//assets/...`. The `exists` check never matches, every asset is
re-uploaded on each deploy, and the manifest points at keys that were
never written.

`s3Storage` already strips the surrounding slashes itself before building
its keys, which is why AWS is unaffected. Doing it in the shared helper
fixes the r2, supabase and firebase plugins, plus any custom plugin built
on `createStorageKeyBuilder` as the storage plugin guide recommends.
@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 52cd8c4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 25 packages
Name Type
@hot-updater/plugin-core Patch
@hot-updater/android-helper Patch
@hot-updater/apple-helper Patch
@hot-updater/cli-tools Patch
@hot-updater/console Patch
hot-updater Patch
@hot-updater/react-native Patch
@hot-updater/server Patch
@hot-updater/aws Patch
@hot-updater/bare Patch
@hot-updater/bugsnag-plugin Patch
@hot-updater/cloudflare Patch
@hot-updater/datadog-plugin Patch
@hot-updater/expo Patch
@hot-updater/firebase Patch
@hot-updater/mock Patch
@hot-updater/postgres Patch
@hot-updater/rock Patch
@hot-updater/sentry-plugin Patch
@hot-updater/standalone Patch
@hot-updater/supabase Patch
@hot-updater/bsdiff Patch
@hot-updater/core Patch
@hot-updater/test-utils Patch
@hot-updater/js Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

@giaBaoJS is attempting to deploy a commit to the hot-updater Team on Vercel.

A member of the Team first needs to authorize it.

@pkg-pr-new

pkg-pr-new Bot commented Sep 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@hot-updater/android-helper

npm i https://pkg.pr.new/@hot-updater/android-helper@1249

@hot-updater/apple-helper

npm i https://pkg.pr.new/@hot-updater/apple-helper@1249

@hot-updater/bsdiff

npm i https://pkg.pr.new/@hot-updater/bsdiff@1249

@hot-updater/cli-tools

npm i https://pkg.pr.new/@hot-updater/cli-tools@1249

@hot-updater/console

npm i https://pkg.pr.new/@hot-updater/console@1249

@hot-updater/core

npm i https://pkg.pr.new/@hot-updater/core@1249

hot-updater

npm i https://pkg.pr.new/hot-updater@1249

@hot-updater/react-native

npm i https://pkg.pr.new/@hot-updater/react-native@1249

@hot-updater/server

npm i https://pkg.pr.new/@hot-updater/server@1249

@hot-updater/aws

npm i https://pkg.pr.new/@hot-updater/aws@1249

@hot-updater/bare

npm i https://pkg.pr.new/@hot-updater/bare@1249

@hot-updater/bugsnag-plugin

npm i https://pkg.pr.new/@hot-updater/bugsnag-plugin@1249

@hot-updater/cloudflare

npm i https://pkg.pr.new/@hot-updater/cloudflare@1249

@hot-updater/datadog-plugin

npm i https://pkg.pr.new/@hot-updater/datadog-plugin@1249

@hot-updater/expo

npm i https://pkg.pr.new/@hot-updater/expo@1249

@hot-updater/firebase

npm i https://pkg.pr.new/@hot-updater/firebase@1249

@hot-updater/js

npm i https://pkg.pr.new/@hot-updater/js@1249

@hot-updater/plugin-core

npm i https://pkg.pr.new/@hot-updater/plugin-core@1249

@hot-updater/postgres

npm i https://pkg.pr.new/@hot-updater/postgres@1249

@hot-updater/rock

npm i https://pkg.pr.new/@hot-updater/rock@1249

@hot-updater/sentry-plugin

npm i https://pkg.pr.new/@hot-updater/sentry-plugin@1249

@hot-updater/standalone

npm i https://pkg.pr.new/@hot-updater/standalone@1249

@hot-updater/supabase

npm i https://pkg.pr.new/@hot-updater/supabase@1249

commit: 52cd8c4

@gronxb gronxb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Confirmed that slash-wrapped basePath values produce mismatched asset keys, and this fix restores consistent upload and manifest paths. Thanks for the fix and regression coverage!

@gronxb
gronxb merged commit ee20ef2 into gronxb:main Sep 6, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants