Skip to content

Images

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.

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.

Required on every image, so product imagery is never invisible to a screen reader. The editor asks for it.

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_BUCKET and its group are set: AWS, DigitalOcean Spaces, Cloudflare R2, Backblaze, MinIO. Under this driver the server answers /assets/<path> with a redirect to ASSETS_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.

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.