Images
What an upload becomes
Section titled “What an upload becomes”Every upload is re-encoded by sharp, which is what strips EXIF and anything
appended after the image data, and capped at 2400 px. Resized copies are written
alongside the original at 400, 800, 1200 and 1600 px, skipping any width at or above
the source so nothing is upscaled. The widths actually generated are recorded on
the row, so the storefront advertises only files that exist, and an image uploaded
before derivatives existed keeps working with a single src.
The effect is worth stating plainly: a thumbnail rendered 70 px wide downloads 2.3 kB instead of a 17 kB original.
MAX_UPLOAD_BYTES (20 MB) is the ceiling, with room for a camera original.
The naming contract
Section titled “The naming contract”abc.webp at 800 wide is abc-800.webp. The rule lives in shared/images.ts,
imported by both the server that writes the files and the storefront that names
them in srcset, because nothing type-checks that the two agree: a drift would be
a 404 per image rather than a compile error. buildSrcSet returns null for an
image with no derivatives, since srcset="" is not the same as no attribute.
Alt text
Section titled “Alt text”Required on every image, so product imagery is never invisible to a screen reader. The editor asks for it.
Where the files live
Section titled “Where the files live”The database stores a relative path, products/abc.webp, and the storefront always
asks for it at /assets/<path>. Where the bytes are is a driver behind the
ImageStore interface in server/image-store.ts:
- Local, the default. Written to and served from
ASSETS_DIR(public/assets). Point it at a volume on any platform that rebuilds the filesystem on deploy, or every image vanishes with the next release. - S3-compatible, when
ASSETS_S3_BUCKETand its group are set: AWS, DigitalOcean Spaces, Cloudflare R2, Backblaze, MinIO. Under this driver the server answers/assets/<path>with a redirect toASSETS_PUBLIC_URL/<path>, cached for a month, rather than teaching every URL builder a second base. The Content-Security-Policy is widened by that one origin at boot.
Switching drivers is configuration, not a data migration, because the stored path
is the same under both. Moving an existing directory into a bucket is task 28,
npm run assets:migrate, not yet built; until then, copy the tree into the bucket
with the provider’s tool, keeping the same keys.
A half-configured bucket group is refused at boot with the missing variables named. The boot log says which driver is active next to the port. The bundled demo images stay on disk under either driver.
The seam
Section titled “The seam”A third backend implements put, delete and publicUrl. The traversal guards
apply under every driver: a key of ../x is refused before it reaches the store.